diff --git a/CHANGELOG.md b/CHANGELOG.md
index fbcb0e08..187b07f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -211,6 +211,21 @@ follow semantic versioning; release dates are ISO 8601.
### Documentation
+- **The chart recipe documents the styling surface it has.** Five settings were named
+ nowhere on the page — the three text styles, the donut centre style and the bar width
+ ratio — four of them already load-bearing in the flagship examples that put a chart on
+ a dark card, and discoverable only by reading the builder's source. The
+ value-label halo was filed under line charts, the one place its default white chip is
+ least likely to be wrong, rather than described as what it is: the backing behind
+ value and slice labels alike, and the first thing that must move when a chart leaves a
+ white page. And `ChartTheme` was presented as a layer an author styles through, though
+ no authoring API accepts one — a chart resolves its geometry after the document's theme
+ is out of reach, so `ChartStyle` is the author-facing whole of it. The low-level
+ `ChartLayoutResolver.resolve(...)` does take a `ChartTheme`, and the page now says so
+ rather than leaving it out. The page carries every setting
+ with its default and what it affects, worked examples for typography and for the halo,
+ and `ChartStyleDocumentationGuardTest` fails the build when a setter reaches the builder
+ without reaching the page.
- **One recipe catalogue instead of two.** The cookbook page and the folder index each
carried a hand-maintained table of all twenty-two recipe pages. They happened to agree
on which pages exist and disagreed on ten of the descriptions — the folder index named
diff --git a/core/src/test/java/com/demcha/documentation/ChartStyleDocumentationGuardTest.java b/core/src/test/java/com/demcha/documentation/ChartStyleDocumentationGuardTest.java
new file mode 100644
index 00000000..152d7fe3
--- /dev/null
+++ b/core/src/test/java/com/demcha/documentation/ChartStyleDocumentationGuardTest.java
@@ -0,0 +1,75 @@
+package com.demcha.documentation;
+
+import com.demcha.compose.document.chart.ChartStyle;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import java.util.TreeSet;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Every knob on {@link ChartStyle.Builder} is named in the chart recipe.
+ *
+ *
A styling setter nobody documents is a setter nobody finds. The recipe is
+ * the only page that describes chart styling, and a new field added to the
+ * builder does not disturb it — the page keeps rendering, every guard keeps
+ * passing, and the option stays invisible until someone reads the source. Five
+ * had accumulated that way: the three text styles, the donut centre style, and
+ * the bar width ratio, all of them used by the flagship examples and named
+ * nowhere a reader would look.
+ *
+ * The check is deliberately shallow — it asserts the setter's name occurs on
+ * the page, not that the prose around it is any good. That is enough to make
+ * the omission loud at the moment it is introduced, which is the only moment
+ * it is cheap to fix.
+ */
+class ChartStyleDocumentationGuardTest {
+
+ private static final Path RECIPE = RepoRoot.get().resolve("docs/recipes/charts.md");
+
+ @Test
+ void everyChartStyleSetterIsNamedInTheChartRecipe() throws IOException {
+ assertThat(RECIPE)
+ .describedAs("the chart recipe moved; this guard is no longer reading the page it protects")
+ .exists();
+
+ String page = Files.readString(RECIPE, StandardCharsets.UTF_8);
+
+ List setters = builderSetterNames();
+ assertThat(setters)
+ .describedAs("no setters found on ChartStyle.Builder — the guard would cover nothing")
+ .isNotEmpty();
+
+ TreeSet undocumented = new TreeSet<>();
+ for (String setter : setters) {
+ if (!page.contains(setter + "(")) {
+ undocumented.add(setter);
+ }
+ }
+
+ assertThat(undocumented)
+ .describedAs("ChartStyle.Builder setters missing from docs/recipes/charts.md — a "
+ + "styling option that is not on the page is one readers cannot discover")
+ .isEmpty();
+ }
+
+ /** Public builder methods that configure the style, i.e. everything but {@code build}. */
+ private static List builderSetterNames() {
+ return Arrays.stream(ChartStyle.Builder.class.getDeclaredMethods())
+ .filter(m -> Modifier.isPublic(m.getModifiers()))
+ .filter(m -> !m.isSynthetic())
+ .map(Method::getName)
+ .filter(name -> !"build".equals(name))
+ .distinct()
+ .sorted()
+ .toList();
+ }
+}
diff --git a/docs/recipes/charts.md b/docs/recipes/charts.md
index 54674556..5d480037 100644
--- a/docs/recipes/charts.md
+++ b/docs/recipes/charts.md
@@ -12,8 +12,8 @@ The API is split into independent layers so nothing is baked in:
|---|---|---|
| Data | `ChartData` | *what numbers* — categories + series, knows nothing about type or colour |
| Spec | `ChartSpec` (sealed: `bar()` / `line()` / `pie()`) | *what to show* — orientation, axes, legend, labels, sizing |
-| Style | `ChartStyle` over `ChartTheme` tokens | *how it looks* — cascading nullable fields, CSS-style merge |
-| Geometry | `ChartLayoutResolver` | internal pure function `(data, spec, style) → primitives` |
+| Style | `ChartStyle` | *how it looks* — nullable fields merged CSS-style over the built-in `ChartTheme` |
+| Geometry | `ChartLayoutResolver` | *where the shapes come from* — a pure `(spec, style, theme, size, metrics) → primitives` function |
All chart types live in `com.demcha.compose.document.chart`.
@@ -57,6 +57,9 @@ section.chart(ChartSpec.bar()
reading order, values grow right, labels sit at the bar ends.
- `AxisSpec.min(...)` / `max(...)` pin the axis to explicit bounds; ticks
still land on nice 1/2/5 values.
+- `ChartStyle.barWidthRatio(...)` sets how much of a category slot the bar
+ group fills (default `0.72`). Lower it for airy, editorial bars; raise it
+ toward `1.0` to close the gaps.
## Line, smooth, and area charts
@@ -90,11 +93,13 @@ section.chart(lineSpec, ChartStyle.builder()
Markers are ellipses (`PointMarker.circle(d)` / `ellipse(w, h)`) drawn
**above every stroke**, so joints where lines meet stay readable; the white
-ring is the classic separator. Per-point value labels draw above markers
-behind a halo chip (`ChartStyle.valueLabelHalo`, themed white — match it to
-your card colour on tinted surfaces, including translucent paints via
-`DocumentColor.rgba(...)`). When two series' labels would collide at the same
-category, the lower one automatically flips below its point.
+ring is the classic separator. Per-point value labels draw above their marker,
+each behind a halo chip — see [the value-label halo](#the-value-label-halo).
+When two series' labels would collide at the same category, the lower one
+automatically flips below its point.
+
+`valueLabelOffset` (default `2`) is the gap between a label and the thing it
+labels — the marker here, the bar end on a bar chart, the rim on a pie or donut.
## Pie and donut
@@ -111,9 +116,36 @@ section.chart(ChartSpec.pie()
.build());
```
-Slices are arc-tessellated vector polygons. `sliceStroke` (themed white 1pt)
-separates adjacent slices; `startAngleDegrees` / `clockwise(false)` control
-layout. Negative values and multi-series data are rejected loudly.
+Slices are arc-tessellated vector polygons. `sliceStroke` (white 1pt by
+default) separates adjacent slices; `startAngleDegrees` / `clockwise(false)`
+control layout. Negative values and multi-series data are rejected loudly.
+
+`centerText` is the KPI in the hole, and `ChartStyle.donutCenterTextStyle(...)`
+is what sizes and colours it — 13pt bold dark grey unless you say otherwise.
+It is a plain `DocumentTextStyle`, so a bigger figure in the brand colour is
+one call:
+
+
+
+```java
+import com.demcha.compose.document.chart.ChartStyle;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.font.FontName;
+
+ChartStyle kpiDonut = ChartStyle.builder()
+ .donutCenterTextStyle(DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA)
+ .decoration(DocumentTextDecoration.BOLD)
+ .size(22)
+ .color(DocumentColor.rgb(20, 80, 95))
+ .build())
+ .build();
+```
+
+Slice labels use `valueLabelTextStyle` and the same halo as every other value
+label.
## Hiding chrome: down to "just the bars"
@@ -133,8 +165,9 @@ ChartSpec.bar().data(revenue)
## Styling: the cascade
-`ChartTheme` tokens → document `ChartStyle` → per-series override, merged like
-CSS (every `ChartStyle` field is nullable = inherit):
+Every `ChartStyle` field is nullable, and null means *inherit*. The style you
+pass to `chart(spec, style)` is merged CSS-style over `ChartDefaults.DEFAULT_THEME`,
+so you set the handful of things you care about and the rest stays consistent:
```java
ChartStyle.builder()
@@ -148,6 +181,107 @@ ChartStyle.builder()
The palette cycles by modulo, so a chart never runs out of colours.
+`ChartTheme` is that base set of tokens, and the authoring API does not currently
+expose a way to swap it: a chart resolves its geometry during the layout pass,
+after the document's theme is out of reach, so every chart placed through the DSL
+starts from `ChartDefaults.DEFAULT_THEME` and `ChartStyle` is the author-facing
+override. Give charts that must match a brand a shared `ChartStyle` constant and
+pass it to each one.
+
+`ChartLayoutResolver.resolve(...)` does take an explicit `ChartTheme`, but it
+returns raw primitives rather than placing a chart in a document — that is the
+geometry seam, useful for tooling and tests, not a second way to author.
+
+### Typography
+
+Three text styles cover the chrome, all plain `DocumentTextStyle` (the fourth,
+[`donutCenterTextStyle`](#pie-and-donut), belongs to the donut hole):
+
+
+
+```java
+import com.demcha.compose.document.chart.ChartStyle;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.font.FontName;
+
+ChartStyle onDark = ChartStyle.builder()
+ .axisTextStyle(label(7.5, DocumentColor.rgb(150, 160, 175))) // ticks + categories
+ .legendTextStyle(label(8, DocumentColor.rgb(150, 160, 175))) // series names
+ .valueLabelTextStyle(label(9, DocumentColor.WHITE)) // numbers on the data
+ .build();
+
+static DocumentTextStyle label(double size, DocumentColor color) {
+ return DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA)
+ .size(size)
+ .color(color)
+ .build();
+}
+```
+
+`axisTextStyle` covers both the numeric ticks and the category labels — they
+are the same chrome and read best when they match. Defaults are 8pt/9pt/8pt
+grey, tuned for a white page: on a dark card you will want to set all three,
+and the halo below along with them.
+
+### The value-label halo
+
+`valueLabelHalo` is the chip drawn *behind* a value or slice label so the digits
+stay legible where the chart's own graphics run under them — a grid line, a
+series stroke, a slice edge. It is a `DocumentPaint`, white by default, which is
+right on a white page and wrong everywhere else: on a tinted card an unset halo
+paints white rectangles across your background.
+
+Set it to the surface the chart sits on:
+
+
+
+```java
+import com.demcha.compose.document.chart.ChartStyle;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentPaint;
+
+static final DocumentColor CARD = DocumentColor.rgb(18, 24, 38);
+
+ChartStyle onCard = ChartStyle.builder()
+ .valueLabelHalo(DocumentPaint.solid(CARD)) // match the card
+ .build();
+
+ChartStyle softened = ChartStyle.builder()
+ .valueLabelHalo(DocumentPaint.solid(CARD.withOpacity(0.6))) // let the grid show
+ .build();
+```
+
+`withOpacity(...)` makes the chip genuinely translucent — real graphics-state
+alpha, so the grid stays faintly visible through it instead of being punched
+out. Useful when a solid chip reads as a sticker. (`DocumentColor.rgba(r, g, b, a)`
+does the same with an integer 0–255 alpha.)
+
+### Every `ChartStyle` field
+
+| Setting | Default | Applies to |
+|---|---|---|
+| `palette(...)` / `seriesPaint(i, ...)` | 8-colour Tableau-inspired palette † | all |
+| `lineWidth(double)` | `1.5` | line |
+| `pointMarker(PointMarker)` | none | line |
+| `areaOpacity(double)` | `0.35` | line with `area(true)` |
+| `barCornerRadius(DocumentCornerRadius)` | square corners | bar |
+| `barWidthRatio(double)` | `0.72` | bar |
+| `grid(GridStyle)` | horizontal, 0.5pt `#E0E0E0` † | bar, line |
+| `axisTextStyle(DocumentTextStyle)` | 8pt `#5A5A5A` † | tick + category labels |
+| `legendTextStyle(DocumentTextStyle)` | 9pt `#3C3C3C` † | legend |
+| `valueLabelTextStyle(DocumentTextStyle)` | 8pt `#3C3C3C` † | value + slice labels |
+| `valueLabelHalo(DocumentPaint)` | white † | chip behind those labels |
+| `valueLabelOffset(double)` | `2` | gap from a line marker, bar end or pie edge |
+| `sliceStroke(DocumentStroke)` | white 1pt | pie, donut |
+| `sliceGapDegrees(double)` | `0` | pie, donut |
+| `donutCenterTextStyle(DocumentTextStyle)` | 13pt bold `#2D2D2D` | donut centre |
+
+† inherited from `ChartDefaults.DEFAULT_THEME`; unmarked rows are fixed engine
+defaults, some of them constants in `ChartDefaults` and some — `pointMarker`,
+square corners — simply the absence of an override.
+
## Inline sparklines
Mini-charts that sit on the text baseline like any other inline shape — a