diff --git a/CHANGELOG.md b/CHANGELOG.md
index 11bbd6d1..17eb8177 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -127,6 +127,25 @@ follow semantic versioning; release dates are ISO 8601.
### Documentation
+- **The example catalogue stops offering a type that ships nowhere.** Its entry-point
+ table introduced the cover letter as a `BusinessTheme.modern()` document, and
+ `BusinessTheme` is a record local to the examples module — a reader adding the
+ dependency and reaching for it finds nothing to import. The rows and prose now say
+ what the examples demonstrate, the advanced section that quotes the helper says
+ plainly that it is examples-local and names `BrandTheme` as the shipping equivalent,
+ and a guard rejects the factory-call form on any README while leaving the quotation
+ of an example's own source alone.
+- **The last hardcoded counts are gone.** The example catalogue named a number of
+ generated documents and a number of committed previews; both sat well below the real
+ inventory, having been reconciled by hand once already and drifted again since. The
+ banner caption stated a line count for the example it links, and named the module graph
+ by a version the image itself no longer shows. All four are removed rather than
+ corrected — a count in prose has no owner and nothing to keep it true, so correcting
+ one only resets the clock.
+- **The contributing guide's commit examples match the convention it asks for.** It
+ pointed at two subjects from the 1.5 line as the shape to copy, while the repository
+ has moved to Conventional Commits and the pull-request template requires that shape in
+ a title. The examples are two real recent subjects instead.
- **Documented headings render bold, and say why.** Six snippets across the
getting-started guide, the root README and the theme, timeline, rich-text and
preset-authoring recipes set `fontName(FontName.HELVETICA_BOLD)` without a
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3372405e..f47981ff 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -59,7 +59,7 @@ Almost all work targets **`develop`**, the ongoing 2.x line. The `1.x` branch ta
git checkout -b feature/short-description
```
Use `feature/...` for new functionality, `fix/...` for bug fixes, and `docs/...` for documentation-only changes. Issue-prefixed names (`42/fix/short-description`) are also welcome — convenient when the branch closes a specific issue.
-3. **Commit small, focused changes.** Each commit message should describe the *why*, not just the *what*. Recent commits on `develop` (`Prepare v1.5.0 release`, `Align public docs with the canonical surface`) are reasonable length and structure templates.
+3. **Commit small, focused changes.** Subjects follow Conventional Commits — `type(scope): the concrete effect` — the same shape the [pull request template](.github/pull_request_template.md) asks of a PR title. Say the *why*, not only the *what*. `git log --oneline -20` on `develop` is the reference; `fix(font): the donut-centre KPI renders the weight it declares` and `ci(guards): run every guard the job names, and compile docs-only pull requests` are the shape.
4. **Run the validation gate locally** before opening a PR:
```bash
./mvnw -B -ntp clean verify
diff --git a/README.md b/README.md
index 1d28f0df..7f49c0fc 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@
- ☝ This banner is itself a GraphCompose document — view the full module-first deck (PDF), rendered by EngineDeckV2Example: the 2.0 module graph, native vector charts, and real comparative benchmarks, all drawn by the engine. It renders its own marketing.
+ ☝ This banner is itself a GraphCompose document — view the full module-first deck (PDF), rendered by EngineDeckV2Example: the module graph, native vector charts, and real comparative benchmarks, all drawn by the engine. It renders its own marketing.
## One source → a PDF and an editable PowerPoint deck
@@ -77,7 +77,7 @@ try (DocumentSession doc = GraphCompose.document(Path.of("twin-output.pdf"))
- ☝ The generated deck open in PowerPoint — the headline is a selected, editable text frame, and the ribbon is live because the slide is built from native shapes. Artifacts: PDF · PPTX · source (TwinOutputExample, ~370 lines, page included).
+ ☝ The generated deck open in PowerPoint — the headline is a selected, editable text frame, and the ribbon is live because the slide is built from native shapes. Artifacts: PDF · PPTX · source (TwinOutputExample, one page, source included).
## Why GraphCompose
diff --git a/examples/README.md b/examples/README.md
index b1597033..128c01f1 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -34,10 +34,10 @@ Generated PDFs land in `examples/target/generated-pdfs/`. The same
`GenerateAllExamples` renders the whole catalogue in one pass — the CV and
cover-letter presets plus invoices, proposals, a schedule, the feature
-demos, and the flagships. The showcase site surfaces the full generated
-catalogue (~53 PDFs); a curated 39-PDF subset is committed under
-[`assets/readme/examples/`](../assets/readme/examples/) for the previews
-linked below.
+demos, and the flagships. The showcase site publishes the whole generated
+catalogue; a curated subset is committed under
+[`assets/readme/examples/`](../assets/readme/examples/) so the previews
+linked below open straight from GitHub.
## Gallery — pick by your goal
@@ -58,7 +58,7 @@ are with the canonical DSL, then jump to its detailed section below.
|---|---|---|
| [CV — single template](#cv-single-template) | One CV via `ModernProfessional.create()` on a `CvDocument` | [PDF](../assets/readme/examples/cv-modern-professional.pdf) · [Source](src/main/java/com/demcha/examples/templates/cv/CvFileExample.java) |
| [Invoice — cinematic V2](#invoice-cinematic-v2) | `ModernInvoice + BrandTheme.invoiceModern()` — the recommended invoice path | [PDF](../assets/readme/examples/invoice-cinematic.pdf) · [Source](src/main/java/com/demcha/examples/templates/invoice/InvoiceCinematicFileExample.java) |
-| [Cover Letter](#cover-letter) | One-page `BusinessTheme.modern()` cover letter with section presets | [PDF](../assets/readme/examples/cover-letter.pdf) · [Source](src/main/java/com/demcha/examples/templates/coverletter/CoverLetterFileExample.java) |
+| [Cover Letter](#cover-letter) | One-page cover letter composed in the canonical DSL, section presets carrying the hierarchy | [PDF](../assets/readme/examples/cover-letter.pdf) · [Source](src/main/java/com/demcha/examples/templates/coverletter/CoverLetterFileExample.java) |
| [Module-first Profile](#module-first-profile) | Authoring directly against `DocumentSession.module(...).paragraph(...)` — DSL-direct, no template | [PDF](../assets/readme/examples/module-first-profile.pdf) · [Source](src/main/java/com/demcha/examples/flagships/ModuleFirstFileExample.java) |
| **Engine Showcase** | Single-page cinematic brand promo — semantic-graph → polished-PDFs visual metaphor with rounded clip frame, magazine headline lockup, KPI cards, capability columns; source of the README hero image | [Source](src/main/java/com/demcha/examples/flagships/EngineShowcase.java) |
| **Engine Deck** | Multi-page **landscape** capability deck — page 1 is a banner infographic (DSL code → engine → backends → **real rendered-document thumbnails**), then an authoring-pipeline walkthrough, and two pages of **real benchmark data** (GraphCompose vs iText 9 vs JasperReports) loaded from a bundled result file and drawn as tables + native charts; the landscape companion to Engine Showcase. The same composition also renders as a **geometry-identical PowerPoint deck** (one page = one editable slide) through `buildPptx(Path)` | [PDF](../assets/readme/examples/engine-deck.pdf) · [Source](src/main/java/com/demcha/examples/flagships/EngineDeckExample.java) · [PPTX source](src/main/java/com/demcha/examples/flagships/EngineDeckPptxExample.java) |
@@ -138,10 +138,11 @@ are with the canonical DSL, then jump to its detailed section below.
### Cover letter
-A one-page modern cover letter — `BusinessTheme.modern()` drives every
-colour and font choice; section presets (`softPanel`, `accentLeft`,
-`accentTop`) carry the visual hierarchy; opening rich-text strip
-highlights the candidate's headline.
+A one-page modern cover letter composed straight in the canonical DSL.
+Section presets (`softPanel`, `accentLeft`, `accentTop`) carry the visual
+hierarchy and an opening rich-text strip highlights the candidate's
+headline. Its colours come from a theme helper local to this module, not
+from library API — the shipping equivalent is `BrandTheme`.
```java
@@ -1130,11 +1131,15 @@ The same composition also emits an editable PowerPoint deck (`BusinessReportPptx
### Master showcase
Fictional "Q2 sample report" combining the canonical surface
-end-to-end: `BusinessTheme` + page background + hero with rotated
+end-to-end: a theme record + page background + hero with rotated
shape container + branded QR + executive summary + zebra-striped
totals table + accent-bordered highlight cards + Code 128 footer
barcode. Reference it when composing your own multi-page documents.
+The `BusinessTheme` record it uses is a helper local to this module, not
+library API — the shipping equivalent is `BrandTheme` and the layered
+presets. Declare your own record the same way, or start from `BrandTheme`.
+
The same composition also emits an editable PowerPoint deck (`MasterShowcasePptxExample` — each page becomes one slide; rich text, the advanced table, and chrome stay native shapes, and only the rotated clip-masked seal and the barcodes rasterise).
[📄 View PDF](../assets/readme/examples/master-showcase.pdf) ·
diff --git a/qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java b/qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java
new file mode 100644
index 00000000..13a2455f
--- /dev/null
+++ b/qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java
@@ -0,0 +1,240 @@
+package com.demcha.documentation;
+
+import com.demcha.compose.qa.RepoPaths;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Keeps helpers that live only in the examples module out of the reader's path.
+ *
+ * {@code BusinessTheme} and its neighbours are declared in
+ * {@code com.demcha.examples.support.theme} and ship in none of the published artifacts.
+ * Explaining an example that uses one is honest — a reader who opens the source will find
+ * it. Writing {@code BusinessTheme.modern()} in an entry-point table is not: it reads as
+ * the way to theme a document, and the reader discovers otherwise only after adding the
+ * dependency and failing to import it. That is how the cover-letter row presented an
+ * examples-local record as the theming API.
+ *
+ * What is rejected is using the type: a call, a constructor, a method
+ * reference, a member access. The bare name in prose stays legal, because these pages
+ * have to be able to name the thing they are describing — except in a table row, where a
+ * bare name is a claim about what that row's document is made of, which is the same
+ * offer in fewer words and is how the defect was written.
+ *
+ * Code blocks are scanned like everything else. A fenced snippet is the most
+ * copy-pasted thing on the page, so exempting fences wholesale would leave the rule
+ * policing only the form nobody copies. The single exemption is a fence introduced by a
+ * {@code doc-example-ignore} marker, which already has to carry a written reason and is
+ * how a page quotes an example's own source verbatim.
+ */
+class ExamplesLocalTypeGuardTest {
+
+ private static final Path PROJECT_ROOT = RepoPaths.repoRoot();
+
+ /**
+ * The helper package whose types a reader cannot import. Read from the source tree
+ * rather than listed: it holds five public types today, and a list would have covered
+ * whichever one was noticed first.
+ */
+ private static final Path EXAMPLES_LOCAL_PACKAGE =
+ PROJECT_ROOT.resolve("examples/src/main/java/com/demcha/examples/support/theme");
+
+ /** Pages whose subject is what a past release contained. Mirrors the canonical guard. */
+ private static final List HISTORICAL_RECORD_PREFIXES = List.of(
+ "CHANGELOG.md", "docs/adr/", "docs/archive/", "docs/migration/",
+ "docs/private/", "docs/roadmaps/", "docs/templates/v1-classic/");
+
+ /**
+ * The decision guide between the removed template surfaces and the layered ones. It
+ * names {@code BusinessTheme.X()} beside its replacement {@code BrandTheme.X()} on
+ * purpose — and the type it means is the canonical record 2.0 removed, which happens
+ * to share a name with the examples-local helper. Naming the old surface is the
+ * document's whole job; the canonical guard allows it here for the same reason.
+ */
+ private static final Set MIGRATION_INVENTORY =
+ Set.of("docs/templates/which-template-system.md");
+
+ /** A marker that exempts the fence below it, with a reason, on a published page. */
+ private static final Pattern QUOTED_SOURCE_MARKER =
+ Pattern.compile("^\\s*$");
+
+ @Test
+ void noPublishedPageOffersATypeThatShipsNowhere() throws IOException {
+ List types = examplesLocalTypes();
+ Set violations = new TreeSet<>();
+
+ for (Path page : scannedPages()) {
+ Scan scan = scan(Files.readAllLines(page, StandardCharsets.UTF_8));
+ String rel = relative(page);
+ for (String type : types) {
+ Pattern used = usage(type);
+ Pattern bareName = Pattern.compile("\\b" + Pattern.quote(type) + "\\b");
+ for (String line : scan.scanned()) {
+ if (used.matcher(line).find()) {
+ violations.add(rel + " uses " + type);
+ } else if (line.stripLeading().startsWith("|") && bareName.matcher(line).find()) {
+ violations.add(rel + " names " + type + " in a table row");
+ }
+ }
+ }
+ }
+
+ assertThat(violations)
+ .describedAs("a type that ships in no artifact — called, constructed, referenced, "
+ + "or offered as what a row's document is made of — reads as the supported "
+ + "way to do it, and the reader finds out otherwise only after adding the "
+ + "dependency. Describe what the example demonstrates, or name the shipping "
+ + "equivalent. To quote an example's own source verbatim, introduce the "
+ + "fence with a doc-example-ignore marker saying so")
+ .isEmpty();
+ }
+
+ /**
+ * Every use of the type: a call, a constructor, a method reference, a member access.
+ *
+ * Restricting the rule to {@code Type.method(} would leave
+ * {@code new BusinessTheme(…)}, {@code BusinessTheme::modern} and
+ * {@code BusinessTheme.DEFAULT} as three unguarded ways to make the same offer. A
+ * member named {@code java} is the one exception — that is a link to the type's own
+ * source file, which is a reader following the trail, not an API being suggested.
+ */
+ private static Pattern usage(String type) {
+ String name = Pattern.quote(type);
+ return Pattern.compile("\\b" + name + "\\s*\\.\\s*(?!java\\b)\\w+"
+ + "|new\\s+" + name + "\\s*[(<]"
+ + "|\\b" + name + "\\s*::");
+ }
+
+ /**
+ * A fence left open swallows the rest of its page.
+ *
+ * Checked per file, because the totals cannot see it: one unbalanced marker turns
+ * everything below it into an exempt block, and the repository's remaining pages keep
+ * every aggregate comfortably in range.
+ */
+ @Test
+ void noPageLeavesAFenceOpen() throws IOException {
+ Set unbalanced = new TreeSet<>();
+ int scanned = 0;
+ for (Path page : scannedPages()) {
+ Scan scan = scan(Files.readAllLines(page, StandardCharsets.UTF_8));
+ scanned += scan.scanned().size();
+ if (scan.fenceLeftOpen()) {
+ unbalanced.add(relative(page));
+ }
+ }
+
+ assertThat(unbalanced)
+ .describedAs("an unclosed code fence exempts every line after it, so the page "
+ + "stops being checked from that point on and nothing says so")
+ .isEmpty();
+ assertThat(scanned)
+ .describedAs("almost nothing was scanned — the page set or the fence handling "
+ + "moved and this guard is reading an empty corpus")
+ .isGreaterThan(1_000);
+ }
+
+ /**
+ * The exemption path is exercised. If no page quotes an example's own source any
+ * more, the fence handling is dead code and the next reader will not know it was ever
+ * needed — and a guard whose only escape hatch is untested tends to acquire a wider
+ * one the first time it fires inconveniently.
+ */
+ @Test
+ void quotingAnExamplesOwnSourceStaysPossible() throws IOException {
+ int exempt = 0;
+ for (Path page : scannedPages()) {
+ exempt += scan(Files.readAllLines(page, StandardCharsets.UTF_8)).exempted().size();
+ }
+
+ assertThat(exempt)
+ .describedAs("no fence is exempted anywhere — either the marker mechanism changed "
+ + "or nothing quotes example source, and the exemption below is untested")
+ .isPositive();
+ }
+
+ @Test
+ void theHelperPackageIsWhereTheGuardThinksItIs() throws IOException {
+ assertThat(examplesLocalTypes())
+ .describedAs("no types found in %s — the examples-local helpers moved and this "
+ + "guard is checking the documentation against an empty list",
+ relative(EXAMPLES_LOCAL_PACKAGE))
+ .isNotEmpty()
+ .contains("BusinessTheme");
+ }
+
+ /** The published pages this rule applies to. */
+ private static List scannedPages() throws IOException {
+ List pages = new ArrayList<>();
+ for (Path page : PublishedDocs.all(PROJECT_ROOT)) {
+ String rel = relative(page);
+ boolean historical = HISTORICAL_RECORD_PREFIXES.stream().anyMatch(rel::startsWith);
+ if (!historical && !MIGRATION_INVENTORY.contains(rel)) {
+ pages.add(page);
+ }
+ }
+ return pages;
+ }
+
+ /** The types declared in the examples module's theme helper package. */
+ private static List examplesLocalTypes() throws IOException {
+ if (!Files.isDirectory(EXAMPLES_LOCAL_PACKAGE)) {
+ return List.of();
+ }
+ try (Stream files = Files.list(EXAMPLES_LOCAL_PACKAGE)) {
+ return files.map(path -> path.getFileName().toString())
+ .filter(name -> name.endsWith(".java"))
+ .map(name -> name.substring(0, name.length() - ".java".length()))
+ .filter(name -> !name.equals("package-info"))
+ .sorted()
+ .toList();
+ }
+ }
+
+ /** What a page contributes: the lines the rule reads, the lines a marker excused, and balance. */
+ private record Scan(List scanned, List exempted, boolean fenceLeftOpen) {
+ }
+
+ private static Scan scan(List lines) {
+ List scanned = new ArrayList<>();
+ List exempted = new ArrayList<>();
+ boolean inFence = false;
+ boolean fenceIsExempt = false;
+ String previous = "";
+ for (String line : lines) {
+ String trimmed = line.stripLeading();
+ if (trimmed.startsWith("```")) {
+ if (inFence) {
+ inFence = false;
+ fenceIsExempt = false;
+ } else {
+ inFence = true;
+ fenceIsExempt = QUOTED_SOURCE_MARKER.matcher(previous.strip()).matches();
+ }
+ continue;
+ }
+ (inFence && fenceIsExempt ? exempted : scanned).add(line);
+ if (!trimmed.isEmpty()) {
+ previous = line;
+ }
+ }
+ return new Scan(scanned, exempted, inFence);
+ }
+
+ private static String relative(Path path) {
+ return PROJECT_ROOT.relativize(path).toString().replace('\\', '/');
+ }
+}