diff --git a/CHANGELOG.md b/CHANGELOG.md
index 94bb520fe..33f387771 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,22 @@ follow semantic versioning; release dates are ISO 8601.
- **`graph-compose-render-pptx` declares PDFBox.** It compiles against
`org.apache.pdfbox` types while declaring only `fontbox`, taking the rest
transitively; the resolved version is unchanged.
+- **The contributor guide stops teaching a removed architecture.** The engine
+ implementation guide still walked a new object through attaching components to an
+ entity, adding a render marker and a container-growth marker, and implementing
+ `Breakable` — a model 2.0 removed, and one the current contributing guide
+ contradicts. It is archived, with a banner naming what replaced it; the extension
+ guide and the package map are now the route for anyone adding a node, a handler or
+ a backend.
+- **Documentation drift fails the build.** Three checks now cover what four
+ releases of hand-fixing kept re-breaking: a relative link in the public docs
+ must resolve, a contributor-facing document must not name a type 2.0 removed,
+ and a published snippet that names a `*_BOLD` font constant must pair it with a
+ decoration — the constant resolves to its base family, so without the decoration
+ the text renders regular. The README release-status block must name a published
+ version and link the tag it names. `SECURITY.md`, `SUPPORT.md`, `ROADMAP.md` and
+ `.github/` are scanned for the first time; historical records are skipped by path,
+ so a new archived page is covered the day it lands.
- **The release publishes the showcase it just built.** `cut-release.ps1` never
ran `GenerateAllExamples`, so the site was synced from whatever happened to be
in `examples/target/generated-pdfs` — nothing at all on a clean checkout, which
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5839fa84b..fd74554ff 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -11,10 +11,9 @@ Read these files first:
- [docs/architecture/package-map.md](./docs/architecture/package-map.md) — what lives in which package, and which of them are internal
- [docs/operations/benchmarks.md](./docs/operations/benchmarks.md) when you touch benchmark tooling, render hot paths, layout hot paths, or performance-facing docs
-[docs/contributing/implementation-guide.md](./docs/contributing/implementation-guide.md)
-goes deeper on the engine, but parts of it still describe the execution layer that
-2.0 removed — read `overview.md` and `package-map.md` first and treat the guide as
-background until it is rewritten.
+[docs/contributing/extension-guide.md](./docs/contributing/extension-guide.md)
+walks the extension points end to end — a new semantic node, a fluent setter, a
+render handler, a whole backend — and is the guide to follow when adding one.
They explain the current public surface, the engine/template split, and the recommended extension points.
@@ -320,7 +319,7 @@ For text-heavy primitives, also read:
- [TextMeasurementSystem.java](./core/src/main/java/com/demcha/compose/engine/measurement/TextMeasurementSystem.java)
- [docs/architecture/overview.md](./docs/architecture/overview.md)
-- [docs/contributing/implementation-guide.md](./docs/contributing/implementation-guide.md)
+- [docs/contributing/extension-guide.md](./docs/contributing/extension-guide.md)
If the primitive should be available to application developers,
expose it through `DocumentDsl` and a public `DocumentNode`, not a
@@ -364,7 +363,7 @@ If a change affects resolved geometry, pagination, or ordering, prefer adding or
- Keep [README.md](./README.md) aligned with the tested examples.
- Keep benchmark values clearly dated when they are refreshed.
- Keep `assets/readme/*` screenshots consistent with the current render outputs.
-- If you add a new extension point or contribution pattern, update [README.md](./README.md), [docs/architecture/overview.md](./docs/architecture/overview.md), and [docs/contributing/implementation-guide.md](./docs/contributing/implementation-guide.md) as part of the same change.
+- If you add a new extension point or contribution pattern, update [README.md](./README.md), [docs/architecture/overview.md](./docs/architecture/overview.md), and [docs/contributing/extension-guide.md](./docs/contributing/extension-guide.md) as part of the same change.
- If you change benchmark flow, benchmark artifact layout, or diff selection rules, update [README.md](./README.md) and [docs/operations/benchmarks.md](./docs/operations/benchmarks.md) in the same change.
- Visual PDF artifacts are grouped under `target/visual-tests/clean/*` and `target/visual-tests/guides/*` so guide-line renders are easy to find separately from clean outputs.
diff --git a/core/src/test/java/com/demcha/documentation/CanonicalSurfaceGuardTest.java b/core/src/test/java/com/demcha/documentation/CanonicalSurfaceGuardTest.java
index cb3b05a47..0ba64a6dd 100644
--- a/core/src/test/java/com/demcha/documentation/CanonicalSurfaceGuardTest.java
+++ b/core/src/test/java/com/demcha/documentation/CanonicalSurfaceGuardTest.java
@@ -8,6 +8,8 @@
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import java.util.function.Predicate;
import java.util.stream.Collectors;
@@ -26,6 +28,62 @@ class CanonicalSurfaceGuardTest {
"ModuleYml",
"ModuleSummary");
+ /**
+ * Types the 2.0 line removed. Every one is absent from every {@code src/main}
+ * tree, so a contributor-facing document naming one is teaching code that cannot
+ * compile — which is how the template-authoring and engine-primitive sections of
+ * CONTRIBUTING went stale for a full release cycle.
+ *
+ *
Deliberately absent: {@code BusinessTheme} survives as an examples-local
+ * theme helper used by twenty-one example sources, and {@code PptxSemanticBackend}
+ * still ships beside the fixed-layout PPTX backend. Forbidding either would fail
+ * the build on text that is correct.
+ */
+ private static final List RETIRED_IN_2_0_TOKENS = List.of(
+ "InvoiceTemplateV2",
+ "ProposalTemplateV2",
+ "WeeklyScheduleTemplateV1",
+ "EntityBounds",
+ "ParentContainerUpdater",
+ "ParentComponent",
+ "Breakable",
+ "hasRender(",
+ "CvSpec",
+ "CvBuilder");
+
+ /**
+ * Types a contributor must not be pointed at when told how to build against the
+ * library, even though the name still resolves somewhere in this repository.
+ *
+ * {@code BusinessTheme} is the case that motivated the list: 2.0 removed it from
+ * the published surface, but the examples keep a local helper of the same name, so a
+ * repository-wide existence check cannot tell "this type is gone" from "this type is
+ * an example's private business". The distinction that matters to a reader is
+ * whether the type ships in a Maven artifact, and for API guidance it does not.
+ *
+ * Scanned over API guidance only — issue templates, the contributing guide, the
+ * template docs — and deliberately not over {@code examples/README.md}, which
+ * documents that local helper correctly.
+ */
+ private static final List FORBIDDEN_IN_API_GUIDANCE = List.of(
+ "BusinessTheme");
+
+ /**
+ * Documents whose job is to record history. A migration guide, an ADR, an archived
+ * page or the changelog names retired surface on purpose, so the retired-token scan
+ * skips them by path rather than by a file list — a new historical page is then
+ * covered the day it is added, instead of failing the build until someone
+ * remembers to allowlist it.
+ */
+ 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/");
+
private static final Set MAIN_CANONICAL_SOURCE_ALLOWLIST = Set.of();
private static final Set DOCUMENTATION_ALLOWLIST = Set.of(
@@ -117,6 +175,142 @@ void publicMarkdownDocsShouldAvoidLegacySurfaceOutsideHistoricalAuditNotes() thr
PUBLIC_MARKDOWN_ALLOWLIST);
}
+ /**
+ * The contributor-facing surface must not teach types 2.0 removed.
+ *
+ * Scans wider than the legacy check above: {@code SECURITY.md},
+ * {@code SUPPORT.md}, {@code ROADMAP.md} and {@code .github/} sit outside every
+ * existing guard, which is why an issue template could route reporters to a theme
+ * class that no longer exists and a pull-request template could offer a lane the
+ * repository dropped.
+ */
+ @Test
+ void contributorFacingDocsShouldNotNameSurfaceRetiredIn2_0() throws IOException {
+ List roots = List.of(
+ PROJECT_ROOT.resolve("README.md"),
+ PROJECT_ROOT.resolve("CONTRIBUTING.md"),
+ PROJECT_ROOT.resolve("SECURITY.md"),
+ PROJECT_ROOT.resolve("SUPPORT.md"),
+ PROJECT_ROOT.resolve("ROADMAP.md"),
+ PROJECT_ROOT.resolve("examples/README.md"),
+ PROJECT_ROOT.resolve("docs"),
+ PROJECT_ROOT.resolve(".github"));
+
+ Set violations = new TreeSet<>();
+ for (Path root : roots) {
+ for (Path doc : markdownUnder(root)) {
+ String rel = relative(doc);
+ if (isHistoricalRecord(rel) || PUBLIC_MARKDOWN_ALLOWLIST.contains(rel)) {
+ continue;
+ }
+ String source = Files.readString(doc);
+ RETIRED_IN_2_0_TOKENS.stream()
+ .filter(source::contains)
+ .forEach(token -> violations.add(rel + " names " + token));
+ }
+ }
+
+ assertThat(violations)
+ .describedAs("these documents name a type 2.0 removed, so anyone following "
+ + "them writes code that does not compile. A document whose purpose "
+ + "is to record the removal belongs under one of %s.",
+ HISTORICAL_RECORD_PREFIXES)
+ .isEmpty();
+ }
+
+ /**
+ * API guidance must not name a type that no longer ships, even when the name still
+ * resolves inside this repository.
+ *
+ * Separate from the retired-token scan because the question is different: not
+ * "does this identifier exist anywhere" but "can a reader of a published artifact
+ * use it". {@code BusinessTheme} answers yes to the first and no to the second,
+ * which is exactly how an issue template came to offer it as the theming entry
+ * point months after 2.0 removed it.
+ */
+ @Test
+ void apiGuidanceShouldNotOfferTypesThatNoLongerShip() throws IOException {
+ List roots = List.of(
+ PROJECT_ROOT.resolve("README.md"),
+ PROJECT_ROOT.resolve("CONTRIBUTING.md"),
+ PROJECT_ROOT.resolve("docs/templates"),
+ PROJECT_ROOT.resolve("docs/getting-started.md"),
+ PROJECT_ROOT.resolve("docs/first-document.md"),
+ PROJECT_ROOT.resolve(".github"));
+
+ Set violations = new TreeSet<>();
+ for (Path root : roots) {
+ for (Path doc : markdownUnder(root)) {
+ String rel = relative(doc);
+ if (isHistoricalRecord(rel) || PUBLIC_MARKDOWN_ALLOWLIST.contains(rel)) {
+ continue;
+ }
+ String source = Files.readString(doc);
+ FORBIDDEN_IN_API_GUIDANCE.stream()
+ .filter(source::contains)
+ .forEach(token -> violations.add(rel + " offers " + token));
+ }
+ }
+
+ assertThat(violations)
+ .describedAs("these documents tell a reader to build against a type that no "
+ + "longer ships in any published artifact. A name that survives as an "
+ + "examples-local helper is still unusable by a consumer.")
+ .isEmpty();
+ }
+
+ /**
+ * Every relative link in the public documentation resolves on disk.
+ *
+ * Needs no token list and cannot go stale: it reads what the documents actually
+ * point at. Renaming a test, archiving a page or deleting an example breaks the
+ * links to it here rather than for a reader.
+ */
+ @Test
+ void publicMarkdownLinksShouldResolve() throws IOException {
+ List roots = List.of(
+ PROJECT_ROOT.resolve("README.md"),
+ PROJECT_ROOT.resolve("CONTRIBUTING.md"),
+ PROJECT_ROOT.resolve("SECURITY.md"),
+ PROJECT_ROOT.resolve("SUPPORT.md"),
+ PROJECT_ROOT.resolve("ROADMAP.md"),
+ PROJECT_ROOT.resolve("examples/README.md"),
+ PROJECT_ROOT.resolve("docs"),
+ // Issue and pull-request templates carry relative links out of
+ // .github/ISSUE_TEMPLATE/, two levels deep — the shape most likely
+ // to break silently when a target moves.
+ PROJECT_ROOT.resolve(".github"));
+
+ Pattern link = Pattern.compile("\\]\\(([^)\\s]+)\\)");
+ Set broken = new TreeSet<>();
+ for (Path root : roots) {
+ for (Path doc : markdownUnder(root)) {
+ if (isHistoricalRecord(relative(doc))) {
+ continue;
+ }
+ Matcher matcher = link.matcher(Files.readString(doc));
+ while (matcher.find()) {
+ String target = matcher.group(1);
+ if (target.startsWith("http") || target.startsWith("mailto:") || target.startsWith("#")) {
+ continue;
+ }
+ String file = target.split("#", 2)[0];
+ if (file.isEmpty()) {
+ continue;
+ }
+ if (!Files.exists(doc.getParent().resolve(file).normalize())) {
+ broken.add(relative(doc) + " -> " + target);
+ }
+ }
+ }
+ }
+
+ assertThat(broken)
+ .describedAs("a relative link in the public docs points at a file that does "
+ + "not exist; the reader gets a 404 on GitHub")
+ .isEmpty();
+ }
+
@Test
void publicAuthoringDocsAndExamplesShouldNotImportEngineInternals() throws IOException {
assertNoForbiddenAuthoringImports(
@@ -140,6 +334,26 @@ void semanticAuthoringValuePackagesShouldNotImportEngineInternals() throws IOExc
PROJECT_ROOT.resolve("core/src/main/java/com/demcha/compose/document/image")));
}
+ /** Markdown files under a root, or the root itself when it is one. */
+ private static List markdownUnder(Path root) throws IOException {
+ if (Files.isRegularFile(root)) {
+ return root.toString().endsWith(".md") ? List.of(root) : List.of();
+ }
+ if (!Files.isDirectory(root)) {
+ return List.of();
+ }
+ try (var paths = Files.walk(root)) {
+ return paths.filter(Files::isRegularFile)
+ .filter(path -> path.toString().endsWith(".md"))
+ .sorted()
+ .toList();
+ }
+ }
+
+ private static boolean isHistoricalRecord(String relativePath) {
+ return HISTORICAL_RECORD_PREFIXES.stream().anyMatch(relativePath::startsWith);
+ }
+
private void assertNoForbiddenReferences(Path root, Set allowlist) throws IOException {
assertNoForbiddenReferences(root, path -> true, allowlist);
}
diff --git a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
index a08475d6c..52a24242d 100644
--- a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
+++ b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java
@@ -216,6 +216,38 @@ void readmeBannerVersionDerivesFromProjectVersion() throws Exception {
.isFalse();
}
+ /**
+ * The README release-status block advertises a version that exists.
+ *
+ * It used to be a hand-edit no test covered, and the release script demanded it
+ * already name the version being cut — so between releases {@code develop} carried
+ * an unpublished version as "latest stable" behind a tag URL that 404s. The script
+ * rewrites the block now; this pins the result, and pins the link to the text so a
+ * half-updated block cannot pass.
+ */
+ @Test
+ void readmeReleaseStatusNamesAPublishedVersion() throws Exception {
+ Set targets = acceptableTargets();
+ String readme = Files.readString(PROJECT_ROOT.resolve("README.md"));
+
+ Matcher stable = Pattern.compile(
+ "\\*\\*Latest stable\\*\\*:\\s*\\[v(\\d+\\.\\d+\\.\\d+)]\\(([^)]+)\\)")
+ .matcher(readme);
+ assertThat(stable.find())
+ .describedAs("README must carry a '**Latest stable**: [vX.Y.Z](…)' release-status "
+ + "line — cut-release.ps1 rewrites it during the cut and aborts without it")
+ .isTrue();
+
+ assertThat(stable.group(1))
+ .describedAs("README 'Latest stable' must name a published release, not the one "
+ + "in development: its tag page does not exist until the cut (one of %s)", targets)
+ .isIn(targets);
+ assertThat(stable.group(2))
+ .describedAs("the 'Latest stable' link must point at the tag it names, or the badge "
+ + "sends readers to a different release than the text claims")
+ .endsWith("/releases/tag/v" + stable.group(1));
+ }
+
@Test
void readmeInstallSnippetsMatchTheProjectVersion() throws Exception {
Set targets = acceptableTargets();
diff --git a/docs/README.md b/docs/README.md
index 03acd26a8..efc6ff574 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -19,7 +19,7 @@ back here.
| **Designer / author** wanting a custom visual style for CVs | [Templates v2 (layered) — authoring presets](templates/v2-layered/authoring-presets.md) |
| **Maintainer of a pre-2.0 caller** (classic `*Spec` + builder templates, removed in 2.0) | [Which template system? — migration map](templates/which-template-system.md) |
| **Contributor adding a new template family** to the library | [Templates v2 (layered) — contributor guide](templates/v2-layered/contributor-guide.md) |
-| **Contributor extending the engine** (new node type, new backend handler) | [Extension guide](contributing/extension-guide.md) → [Implementation guide](contributing/implementation-guide.md) |
+| **Contributor extending the engine** (new node type, new backend handler) | [Extension guide](contributing/extension-guide.md) → [Package map](architecture/package-map.md) |
| **Operator** running GraphCompose in production | [Production rendering](operations/production-rendering.md) → [Performance](operations/performance.md) → [Logging](operations/logging.md) |
---
@@ -59,7 +59,7 @@ back here.
### Contributing
- **[contributing/extension-guide.md](contributing/extension-guide.md)** — add a new node type, backend handler, or theme primitive.
-- **[contributing/implementation-guide.md](contributing/implementation-guide.md)** — internal engine notes for contributors hacking on layout / measurement / pagination.
+- **[architecture/pagination-ordering.md](architecture/pagination-ordering.md)** — how nodes are paginated and ordered, for contributors working on layout / measurement.
- **[contributing/release-process.md](contributing/release-process.md)** — versioning, tag procedure, Maven Central publication.
### Migrations & roadmap
diff --git a/docs/contributing/implementation-guide.md b/docs/archive/implementation-guide.md
similarity index 79%
rename from docs/contributing/implementation-guide.md
rename to docs/archive/implementation-guide.md
index e8b3a80ea..ffcbfbf55 100644
--- a/docs/contributing/implementation-guide.md
+++ b/docs/archive/implementation-guide.md
@@ -1,3 +1,17 @@
+> **Archived.** This guide describes the Entity-Component-System execution layer
+> that 2.0 removed. Its model — attaching components to an entity, render markers,
+> a container-growth marker, `Breakable`, `ParentComponent`, `entity.hasRender()` —
+> no longer exists in any published module, and following it produces code that does
+> not compile.
+>
+> For the pipeline that ships today — `DocumentNode` → `NodeDefinition` →
+> `PreparedNode` → `PlacedFragment` → a fragment handler per fixed-layout backend —
+> read [extension-guide.md](../contributing/extension-guide.md) and
+> [package-map.md](../architecture/package-map.md).
+>
+> Kept because the reasoning about pagination and measurement invariants still
+> explains why parts of the engine look the way they do.
+
# Implementation Guide
This guide explains how to add new objects and engine extensions in GraphCompose without fighting the current architecture.
@@ -21,34 +35,14 @@ That means a new object usually needs the right answer in four areas:
- whether it participates in parent/child layout
- how it gets rendered
-## Keep `Entity` thin
-
-`Entity` is the ECS core object, not the preferred home for new layout helpers.
-
-Use these ownership rules when adding or refactoring engine code:
-
-- put geometry reads in `EntityBounds`
-- put parent container size propagation and page-shift updates in `ParentContainerUpdater`
-- keep render-order policy in the render layer (the `engine.render` contracts and the PDF fragment handlers)
-- treat `Entity.bounding*` and `Entity.updateParentContainer*` as deprecated compatibility wrappers
-
-Rule of thumb:
-
-- if the logic needs `Placement`, `ContentSize`, `Margin`, or parent traversal semantics, it probably belongs in a helper or system utility
-- if the logic only needs identity, component access, or canonical child order, it may belong on `Entity`
-
## Minimum components a new object usually needs
### Render marker
If the object should render something visible, the entity needs a renderable marker component.
-Examples:
-
-- [TextComponent.java](../../core/src/main/java/com/demcha/compose/engine/components/renderable/TextComponent.java)
-- [BlockText.java](../../core/src/main/java/com/demcha/compose/engine/components/renderable/BlockText.java)
-
-Those renderable components are render markers. Prefer keeping them backend-neutral and let renderer-owned handlers perform format-specific drawing.
+Keep the marker backend-neutral and let renderer-owned handlers perform the
+format-specific drawing.
### Engine markers with different jobs
@@ -244,35 +238,31 @@ Preferred extension pattern for new backends:
> **Canonical PDF renderer.** Canonical PDF output is produced by
> `com.demcha.compose.document.backend.fixed.pdf`: `PdfFixedLayoutBackend`
> dispatches each layout fragment to a `PdfFragmentRenderHandler` implementation
-> under `document.backend.fixed.pdf.handlers`. The backend-neutral `engine.render`
-> *contracts* (`Render`, `RenderPassSession`, `RenderStream`)
-> remain the shared render seam — extend PDF drawing by adding or updating a
-> fragment handler, not by touching those contracts.
+> under `document.backend.fixed.pdf.handlers`. Extend PDF drawing by adding or
+> updating a fragment handler, not by widening the backend itself. The PPTX
+> backend consumes the same resolved layout through its own handlers, so a new
+> fragment kind needs one on each side.
Important files:
-- [Render.java](../../core/src/main/java/com/demcha/compose/engine/render/Render.java)
-- [RenderPassSession.java](../../core/src/main/java/com/demcha/compose/engine/render/RenderPassSession.java)
-- [RenderStream.java](../../core/src/main/java/com/demcha/compose/engine/render/RenderStream.java)
- [PdfFixedLayoutBackend.java](../../render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java)
- [PdfFragmentRenderHandler.java](../../render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFragmentRenderHandler.java)
Migration rule for new engine components:
-- implement backend-neutral `Render`, not backend-specific render interfaces
- move PDF drawing into a `PdfFragmentRenderHandler` under `...document.backend.fixed.pdf.handlers`
- use `TextMeasurementSystem` for text width and line metrics instead of reaching through the active renderer
- place PDF-only helper objects alongside the backend in `...document.backend.fixed.pdf`
-- keep page-surface lifetime in a backend-specific `RenderPassSession`, not in engine builders or render markers
- keep resolved draw ordering in the render layer (the PDF fragment handlers), not in pagination utilities
- register a render handler for every engine render marker because the PDF entity path no longer supports a backend-specific render fallback
-### Render-pass session rules
+### Render-pass rules
-The current render seam is deliberately narrower than a full backend abstraction. Use these rules when extending it:
+The render seam is deliberately narrower than a full backend abstraction. Use
+these rules when extending it:
-- `RenderStream` should create one render-pass session, not one stream per entity
-- renderer orchestrators such as `PdfFixedLayoutBackend` should open one session for the whole pass
+- renderer orchestrators such as `PdfFixedLayoutBackend` should open one page
+ pass for the whole render, not one per entity
- single-page handlers should use the session-managed page surface directly
- multi-page handlers should request page surfaces explicitly per fragment or page
- page creation or annotation-only work should use the session's page-availability helper instead of opening a dummy drawing surface
@@ -302,35 +292,6 @@ See [layout-snapshot-testing.md](../operations/layout-snapshot-testing.md) for t
So if your new object needs custom drawing, it is not enough to add a builder. You also need a renderable component with the correct renderer implementation.
-## Where layout hooks in
-
-The layout side uses entity components, not builder classes directly.
-
-Important files:
-
-- [LayoutTraversalContext.java](../../core/src/main/java/com/demcha/compose/engine/core/LayoutTraversalContext.java)
-- [ComputedPosition.java](../../core/src/main/java/com/demcha/compose/engine/components/layout/coordinator/ComputedPosition.java)
-- [EntityBounds.java](../../core/src/main/java/com/demcha/compose/engine/components/geometry/EntityBounds.java)
-- [ParentContainerUpdater.java](../../core/src/main/java/com/demcha/compose/engine/pagination/ParentContainerUpdater.java)
-
-In practice:
-
-- `Anchor`, `Margin`, `Padding`, `ContentSize`, and parent/child links are what matter to layout
-- the builder is just the place where you attach those components
-- `LayoutTraversalContext` should build one deterministic hierarchy snapshot per pass instead of letting each subsystem rediscover roots and children independently
-- `ParentComponent` is the authoritative parent relation, while `Entity.children` is the canonical sibling order
-- if those two sources disagree, traversal code should warn loudly and use a deterministic fallback rather than silently hiding the inconsistency
-- during pagination, descendants should be resolved before parent containers so parent size updates caused by child page shifts are reflected before parent placement is finalized
-
-Use the helpers directly when that intent is what you need:
-
-- read bounds and edges through `EntityBounds` instead of adding more bound helpers to `Entity`
-- update parent container size or shifted positions through `ParentContainerUpdater` instead of growing the `Entity` API further
-
-See [pagination-ordering.md](../architecture/pagination-ordering.md) for a focused explanation of this rule, including why one leaf type can fail while another appears to work.
-
-If those components are missing or inconsistent, the renderer cannot save you later.
-
## Practical checklist for a new object
- choose the correct builder base class
diff --git a/docs/contributing/extension-guide.md b/docs/contributing/extension-guide.md
index baa0d7a6a..082e9524b 100644
--- a/docs/contributing/extension-guide.md
+++ b/docs/contributing/extension-guide.md
@@ -236,9 +236,8 @@ Detailed ownership lives in
- [`docs/architecture/overview.md`](../architecture/overview.md) — high-level architecture
and the canonical-vs-engine boundary.
-- [`docs/contributing/implementation-guide.md`](implementation-guide.md) —
- engine-side ECS extension patterns (component records, system
- registration, low-level harness builders).
+- [`docs/architecture/package-map.md`](../architecture/package-map.md) —
+ what lives in which package, and which of them are internal.
- [`docs/architecture/lifecycle.md`](../architecture/lifecycle.md) — the session, layout, and
render flow end-to-end.
- [ADR 0001 — Shape-as-container](../adr/0001-shape-as-container.md) —
diff --git a/docs/templates/v2-layered/README.md b/docs/templates/v2-layered/README.md
index c1dcc3e83..36a988b59 100644
--- a/docs/templates/v2-layered/README.md
+++ b/docs/templates/v2-layered/README.md
@@ -6,7 +6,7 @@
> `com.demcha.compose.document.templates.cv`.
>
> **Naming note:** through 1.x an older surface also called
-> "Templates v2" (`CvSpec`, `CvBuilder`, presets with `BusinessTheme`)
+> "Templates v2" (the pre-2.0 spec/builder surface, since removed)
> shipped alongside this one; it was removed in 2.0 and its docs are
> archived at [templates/v1-classic/](../v1-classic/README.md).
diff --git a/qa/src/test/java/com/demcha/documentation/DocsBoldFaceGuardTest.java b/qa/src/test/java/com/demcha/documentation/DocsBoldFaceGuardTest.java
new file mode 100644
index 000000000..20efe4997
--- /dev/null
+++ b/qa/src/test/java/com/demcha/documentation/DocsBoldFaceGuardTest.java
@@ -0,0 +1,134 @@
+package com.demcha.documentation;
+
+import com.demcha.compose.qa.RepoPaths;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+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.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Guards published snippets against the font-face trap.
+ *
+ * {@code FontName.HELVETICA_BOLD} does not select a bold face. {@code FontLibrary}
+ * resolves it — and every other {@code *_BOLD} / {@code *_ITALIC} / {@code *_OBLIQUE}
+ * constant — back to its base family, and the face within that family comes from
+ * {@code DocumentTextStyle.decoration(...)}. A style that names the alias and omits the
+ * decoration therefore renders regular, which is what six documented headings did.
+ *
+ * The failure is silent: the snippet compiles, runs, produces a PDF, and the text is
+ * simply the wrong weight. Nothing else in the suite looks at it, which is why the
+ * defect survived in the getting-started guide and four recipes.
+ *
+ * So published snippets name the family, and the alias is refused outright
+ * rather than accepted when paired with a decoration. Requiring the pair would still
+ * admit {@code HELVETICA_BOLD} with {@code decoration(ITALIC)} — not bold, whatever the
+ * constant says — and would keep publishing a form that reads as the weight set twice.
+ * A rule with no exceptions is also a rule with nothing to get subtly wrong.
+ *
+ * Scans published markdown only. Example sources are excluded deliberately: their
+ * rendered output is pinned by layout snapshots and committed previews, so a weight
+ * change there is caught by a different gate, and sweeping them would churn every
+ * committed render.
+ */
+class DocsBoldFaceGuardTest {
+
+ private static final Path PROJECT_ROOT = RepoPaths.repoRoot();
+
+ /** Documents a reader copies from. */
+ private static final List SCANNED = List.of(
+ "README.md",
+ "docs",
+ "core/README.md",
+ "render-pdf/README.md",
+ "render-docx/README.md",
+ "render-pptx/README.md",
+ "templates/README.md",
+ "testing/README.md",
+ "fonts/README.md",
+ "emoji/README.md");
+
+ /**
+ * A face alias: any {@code FontName} constant naming a weight or slant rather than
+ * a family. Derived from the shape of the name so a new alias is covered on sight.
+ */
+ private static final Pattern FACE_ALIAS =
+ Pattern.compile("fontName\\(\\s*FontName\\.([A-Z_]*(?:BOLD|ITALIC|OBLIQUE)[A-Z_]*)\\s*\\)");
+
+ /** Any font selection at all — proves the scan reached live documents. */
+ private static final Pattern ANY_FONT_NAME =
+ Pattern.compile("fontName\\(\\s*FontName\\.[A-Z_]+\\s*\\)");
+
+ /** Historical records name the old form on purpose. */
+ private static final List EXEMPT_PREFIXES = List.of(
+ "docs/adr/", "docs/archive/", "docs/migration/", "docs/private/",
+ "docs/roadmaps/", "docs/templates/v1-classic/");
+
+ @Test
+ void publishedSnippetsNameAFontFamilyRatherThanAFaceAlias() throws IOException {
+ Set violations = new TreeSet<>();
+ int scannedSites = 0;
+
+ for (Path doc : scannedDocuments()) {
+ String relative = relative(doc);
+ if (EXEMPT_PREFIXES.stream().anyMatch(relative::startsWith)) {
+ continue;
+ }
+ String source = Files.readString(doc);
+ Matcher anyFont = ANY_FONT_NAME.matcher(source);
+ while (anyFont.find()) {
+ scannedSites++;
+ }
+ Matcher alias = FACE_ALIAS.matcher(source);
+ while (alias.find()) {
+ violations.add(relative + " selects FontName." + alias.group(1)
+ + " — name the family and set the decoration");
+ }
+ }
+
+ assertThat(scannedSites)
+ .describedAs("found no fontName(FontName.*) site at all: the scan roots moved and "
+ + "this guard is passing vacuously. Zero *alias* sites is the goal — zero "
+ + "font selections of any kind means the scan is not reading the docs.")
+ .isPositive();
+ assertThat(violations)
+ .describedAs("a *_BOLD / *_ITALIC / *_OBLIQUE constant is an alias of its base "
+ + "family, not a face: FontLibrary resolves it back and the face comes "
+ + "from decoration(...). Pairing the alias with a decoration works but "
+ + "reads as the weight set twice, and pairing it with a different one "
+ + "silently contradicts the name. Published snippets name the family.")
+ .isEmpty();
+ }
+
+ private static List scannedDocuments() throws IOException {
+ List documents = new ArrayList<>();
+ for (String entry : SCANNED) {
+ Path root = PROJECT_ROOT.resolve(entry);
+ if (Files.isRegularFile(root)) {
+ documents.add(root);
+ } else if (Files.isDirectory(root)) {
+ try (Stream walk = Files.walk(root)) {
+ walk.filter(Files::isRegularFile)
+ .filter(path -> path.toString().endsWith(".md"))
+ .sorted()
+ .forEach(documents::add);
+ }
+ }
+ }
+ return documents;
+ }
+
+ private static String relative(Path path) {
+ return PROJECT_ROOT.relativize(path).toString().replace('\\', '/');
+ }
+}