Build order

Each milestone has an acceptance test that can be run, not a definition of done that has to be argued about. gtfs.media is the test subject throughout: it exists, it is representative, and rebuilding it proves the model covers real work rather than a fixture.

M1: One page, and the working loop

Parser, schema system, Nunjucks environment, hast pipeline, serializer, normalizer, class allowlist, and cms dev with incremental rebuild and live reload.

Components: prose, hero, cards, cta. Layouts: landing, legal. Partials: head, header, footer, theme toggle. The locale dimension is in the content path and the router from this milestone, even though only one locale exists yet.

Acceptance. src/content/en/index.md, hand-written against the model, builds to HTML that renders identically to the current gtfs.media home page above the fold. The class allowlist rejects an injected unknown class. A rebuild produces byte-identical output. Saving the file updates the browser without a manual step.

M2: Assets

Images: sharp variant generation, sizes derivation from component schemas, the manifest, cms media add, the picture renderer, the alt-text gate, and the dev-mode path that skips AVIF. Fonts: fonts.yml, subsetting, the woff2 step, the licence requirement, the preload, and the budget.

Acceptance. The home page hero and both screenshot figures render at derived widths with sizes matching what the CSS does at each breakpoint. Lighthouse mobile reports an LCP under budget and zero layout shift. Adding an image without alt text fails. Declaring a face with no licence file fails. Swapping --fp-sans for a second face changes the page and nothing else.

Done. Images: sidecars, derived widths, AVIF/WebP/fallback, the content-hash cache, cms media add|list|explain, and the alt gate. A 565 KB original serves 9 KB at mobile width, and the derivation is inspectable and overridable per image (MEDIA.md). Fonts: fonts.yml, content subsetting, the licence gate, the generated stylesheet and preload (FONTS.md). Budgets: per-page transfer weight computed from the build, plus font and site.css budgets (BUDGETS.md).

Lighthouse itself moved to M7, where a preview deployment exists to run it against. The reasoning is in BUDGETS.md: a browser-measured number is the wrong gate for an edit loop and the right one for CI.

M3: The gate · done

cms check end to end: markup validity, content rules, axe across two colour schemes and three widths, the internal link graph, and the engine-specific checks in ACCESSIBILITY.md.

Acceptance. The check passes against the M1 and M2 output. Each engine-specific check has a fixture that fails it, so the checks are known to fire. cms check --fast completes in under a second, which is what makes it usable inside the edit loop.

Done. Static checks run in 0.2s; the full pass runs axe in a real browser across two colour schemes at 320, 768, and 1280 pixels. Target size and focus-not-obscured are measured rather than inferred. Four real defects on the first run, listed in ACCESSIBILITY.md. Still to build: 2.4.13 Focus Appearance and the glossary first-use check for 3.1.3.

M4: The Claude Code layer · done

Prompt slots and their override modes, cms sync, the generated skill and AGENTS.md, the hook block, cms docs, cms explain, and the structural CLI commands.

Acceptance. In a fresh Claude Code session with no context beyond the repo, these all land correctly and pass the gate:

  • "Add a fourth card to the modules section on /displays/ pointing at the schedules module."
  • "The hero lead on the home page is too long. Tighten it and keep the two links."
  • "Move /bay-area/ to /california/ and keep the old URL working."
  • "Add the pylon photo to the displays page under the first section."

Two failure cases matter as much: a write that breaks a schema comes back as hook feedback the agent recovers from, and a write into public/ is denied. Separately, a person who has never seen this format reads docs/components/ and writes a valid page.

Done. Seven prompt slots composing engine defaults with site overrides in append, prepend, or replace mode. cms sync writes the skill, the hook block, and a managed section in AGENTS.md; cms docs writes the human reference. The composed prompt is 1,898 words, reported per slot so the budget stays visible. The structural CLI commands are still to build.

M5: Push to deploy · done

GitLab CI template, wrangler deploy on the default branch, wrangler versions upload on branches with the preview URL posted to the merge request, the R2 image cache, and branch protection.

Acceptance. A content commit on a branch produces a preview URL on the merge request within the time budget. A commit that fails the check produces no production deployment. An unchanged image is not regenerated on a second pipeline run.

Done. The CI template, the wrangler config, and a live deployment at gtfs-media.fivepaths.com serving the migration for comparison against the live site. One rule changed on contact with reality: production requires a passing gate and a preview does not, because a preview you cannot see is not a preview. See DEPLOY.md.

M6: The full site · done

The remaining components, page generators, the API-reference generator reading openapi.yaml, the glossary generator, cms import, and the migration of all thirteen pages.

Done. All twelve pages pass the full gate.

18 components ship, plus generator support and a site-local one. The content re-migration against 18 components rather than 9 took the prose share on authored pages from 62.8% to 31.7%, with /displays/ and /maps/ at zero. No markdown images remain: every one went into shots or features, so all of them now carry derived widths and sidecar alt text.

/api/ became a generator reading openapi.yaml: 100 typed blocks, 59 tables all captioned, zero broken anchors where the hand-migrated version had 37.

Four bugs the comparison found, in the order they were hiding behind each other. Each was only visible once the one in front of it was fixed:

  1. base.css v2 sets max-width: 100% and never height: auto, so every dimensioned image distorted. A 1800x1012 screenshot rendered at 493x1012.
  2. Stylesheets were not content-hashed, so the fix for (1) could not reach a browser that had already cached the old file.
  3. Content-addressed assets served at max-age=0, and a _headers catch-all alongside a specific rule made Cloudflare emit two Cache-Control values.
  4. The browser check loaded pages over file://, where /assets/site.90919f496303.css resolves to the filesystem root. Every layout measurement since M3 had been running against pages missing two of their three stylesheets. Fixing it dropped the error count from 83 to 43 on its own.

A fifth followed from (4): the check measured lazy images that had never loaded, reporting 0x0 boxes as target-size failures. It now forces them in and waits on document.fonts.ready before measuring anything.

Acceptance. Every page builds. The rendered output matches the current site after normalization, except where the current site has a real accessibility defect, and each of those is listed with its fix. Header, footer, and theme script exist once. The API reference is generated from the spec and passes the same checks as every other page. A component the importer could not match is either added to the catalogue or recorded as a deliberate raw block.

M7: Translation · done

Locale strings, the language switcher, hreflang and x-default, the translation lock file and its staleness rules, cms translate, per-locale subsetting, and the per-locale conformance ledger.

Acceptance. A second locale builds and serves under its prefix with the default locale's URLs unchanged. Editing an English page marks its translations stale and the check reports it. A machine-drafted page is visibly unreviewed until someone signs it. The full check runs every page in every locale at three widths and the nav still holds at the longest string.

Done. All of it, on the fixture with a Spanish locale: four pages across two locales pass the full gate, including axe in both colour schemes at three widths. Staleness reuses the same shape as an accessibility attestation and a context record, which is now three uses of one idea. Font subsetting is per locale and keyed on the subset's own hash, so two locales needing the same glyphs share a file.

Right-to-left remains out of scope and a site declaring an RTL locale should still be refused rather than shipped broken. That refusal is not yet implemented.

M8: Ingest · done

cms ingest, the /ingest skill, cross-page chrome detection, the block matcher wired to a crawl, media import with alt-text flagging, the OKLCH theme deriver, font import, and the report.

Done. cms ingest <url> crawls, separates chrome from content, matches blocks, imports media, derives a repaired token palette, and writes a report. --vibe relaxes fidelity and records what it set aside; the default keeps the copy, and the mode becomes a standing instruction in the site's prompt slot.

Against the answer key, the theme deriver recovered 7 of 9 tokens exactly and the other two within an imperceptible distance, and independently reproduced the design system's own v1-to-v2 colour repair to within ΔE 0.010. The matcher reached 24.6% prose, better than the 31.7% the hand migration managed, while keeping 97.6% of the source vocabulary.

The number that mattered was the second one. The matcher first reported 11.6% prose and was quietly dropping 27% of the words: a recogniser fired on one feature it found and discarded the rest of its section. Lower prose looked like a better result while being a worse page. The coverage guard is what makes the figure mean anything.

Acceptance. Two runs, in order. Ingesting gtfs.media and comparing against the M6 hand migration measures the matcher against an answer key: which blocks it got right, which it dropped to prose, and whether the derived tokens land near the palette the site already uses. Then an unfamiliar site of twenty to fifty pages builds, passes the gate, and comes in under the site.css line budget, with a report worth working through.

M9: Conformance · done

The ledger, the invalidation rules, cms conformance, and the generated accessibility statement.

Acceptance. Every AAA criterion has a tier and an entry, per locale. Changing prose on a page invalidates the reading-level attestation for that page and the check fails until it is re-signed. The statement page reflects the ledger.

Done. All 86 WCAG 2.2 criteria, with engine defaults per tier, a signing command that refuses a signature without a person, invalidation verified by editing a page and watching 3.1.5 go stale, and trigger detection that revokes a not applicable answer when a form or a video appears.

One thing changed on contact with reality: unsigned reviews warn rather than block, until a site declares claim: AAA. Blocking by default made a fresh site unshippable and would have taught people to switch the check off; never blocking would make the claim meaningless. Shipping and claiming are different acts, and only the second one is a statement about the site.

M10: Second site

Install the engine into pdfaa.report. This is where the boundary between the engine and the site layer gets drawn for real: anything gtfs.media needed that turns out to be general moves into the engine, and anything a second site needs that the catalogue lacks tests the escape hatches.

Acceptance. pdfaa.report builds and passes the gate with a site.css no longer than it is today and with prompt overrides that are additive rather than replacements.

Later

  • Right-to-left layout. A logical-property pass across base.css, which is a design system project in the fivepaths-cdn repo rather than a content engine one. Until it happens, a site declaring an RTL locale fails the check with that explanation.
  • A hosted surface. Another client of the same CLI, for editors who should not be handed a terminal. The seams are described in ARCHITECTURE.md; nothing before M10 should assume it.
  • Multi-tenant. Follows the hosted surface, not the other way around.

What could go wrong

The catalogue calcifies. If adding a component feels expensive, authors reach for raw and the guarantees drain away. cms check reports raw-block usage on every run so the drift is visible early, generators absorb most of the cases that would otherwise reach for raw, and M10 exists to exercise the path.

The gate gets slow. Rendering every page in every locale, in two schemes at three widths, with axe and Lighthouse, is not free. If the full check stops fitting in the working day, people route around it. The fast path in M3 is the mitigation, and its budget is an acceptance criterion rather than an aspiration.

The importer round-trip is treated as the goal. It is a test of catalogue coverage, not a target. Where the current site is wrong, the rebuilt page should differ, and M6 asks for those differences to be listed rather than matched.

Ingest sets the wrong expectation. A client who hears "we can ingest your site" may hear "we will reproduce it". Ingest reads content and brand colours and rebuilds both inside a design system that already meets AAA. That is worth saying in the first conversation rather than at review, and INGEST.md says it in the product's own words for reuse.

Machine translation ships unreviewed. The lock file records who reviewed a translation and when, and the check reports the pages nobody has. Whether that blocks a deploy is a per-site setting, and picking it casually is how a site ends up publishing text nobody who reads the language has read.