ADR-0004 — CMS adapter pattern and loud-fail discipline at every layer¶
Status: Accepted Date: 2026-05-09 / 2026-05-10 Deciders: Cathal Dempsey Related: ADR-0001 (fulldev as foundation), ADR-0003 (assembler-fulldev)
Context¶
ADR-0001 chose fulldev/ui as the canonical block library, consumed by the per-site assembler. ADR-0003 specified how the assembler emits fulldev components for each matched section. That covers the "1800 static sites" path.
The other path is the multi-tenant CMS worker (apps/cms/) — a
single Cloudflare Worker + D1 + R2 + emdash that serves CMS-driven
content for many sites. It needs to render the same block types the
assembler does, but from Portable Text in D1 instead of from extracted
JSON. Two implicit questions had to be answered before adding any
block:
- Does the CMS get its own component library (Path B), build on top of fulldev (Path A), or write a thin wrapper around the canonical (Path C)?
- What happens when something goes wrong inside any of these layers — wrong block type in D1, missing image, unrecognised icon name, malformed Portable Text node?
The first question reaches into how much code we double; the second reaches into how invisible bugs become.
Decisions¶
1. Path A — adapters wrap canonical fulldev components¶
Adopted. Each CMS block type has a thin adapter at
apps/cms/src/components/fcr-blocks/<Name>.astro (~20–50 lines) that
maps a Portable Text node to a canonical block at
packages/components-v3/src/components/blocks/<name>.astro.
The adapter does field-name translation, section-heading slot handling, and trivial defaults. It does not own design or layout — those live in the canonical block, which is also what the assembler emits.
Alternatives considered¶
Path B — fresh CMS-only component library. A second component
library inside apps/cms/src/components/blocks/ that mirrors the
canonical's blocks. Discussed and rejected.
Path C — wholesale rewrite to a third foundation. Drop fulldev entirely for the CMS path. Discussed and rejected immediately.
What settled it¶
The fulldev/ui upstream commit rate measured during evaluation: ~50 commits in 7 days. That number is not just upstream-velocity trivia — it determines what "two parallel libraries" actually costs to maintain.
- Path B forks the design surface in two. Every fix to the canonical has to be re-applied in the CMS clone (or vice versa). At ~50 commits/week of upstream movement plus our own forks, drift becomes a tax we'd pay every session.
- Path C compounds Path B's cost with re-evaluation expense (we already chose fulldev in ADR-0001 against AstroWind and bejamas; redoing that exercise for the CMS is wasted motion).
- Path A keeps the canonical as the single source of truth. CMS-side variation lives in the thin adapter, where the cost is bounded (50 lines, one file per block type).
2. Loud-fail discipline at every layer¶
Silent failure is unacceptable. Bad/missing input renders a visible placeholder + emits an SSR warning. Applied at three layers:
Block-dispatcher layer¶
FcrBlocks.astro carries an explicit MAP from _type to component.
Anything not in MAP renders MissingBlock.astro — a red dashed
banner showing the unknown type and the block's data, plus a
console.warn to the SSR log. There is no silent fall-through.
scripts/check-renderers.mjs (prebuild hook) refuses to ship the
build if any block type declared in the FCR plugin schema has no
dispatcher entry, or points at a missing file. Site-globals
(fcr.header, fcr.topbar, etc.) are checked separately against
Base.astro's globals lookup.
ALLOW_MISSING_RENDERERS=1 exists as an escape hatch but the script
is intentionally loud about it being a smell:
The discomfort is the point. It's there for local-dev "I'm working on the renderer right now" cases, not for production.
Primitive layer¶
A 22-family audit of UI primitives on 2026-05-09 surfaced 5 silent- failure bugs:
Icon— silently emitted nothing for unmatched namesImage/LogoImage/AvatarImage— silent on missing srcRating— silent on0,NaN, malformed numerics
Each was given the same treatment: when input is missing or
unrecognised, render a ui-missing-{icon,image,logo,avatar,rating}
red dashed inline placeholder + log a console warning. The placeholder
is deliberately ugly — operator attention is the whole point.
Two canonical block fixes were made along the way:
- reviews-1.astro made the avatar conditional on item?.image?.src
to avoid placeholder noise when CMS schema legitimately has no
headshot field. (Loud-fail is correct for unintended absence;
structurally optional fields shouldn't fire it.)
- team-grid.astro removed a {image?.src && ...} short-circuit
that was silently swallowing the missing-image case before the
Image primitive's loud-fail could fire.
Two adapter de-filterings: LogoStrip and Gallery were dropping
items with missing src via .filter(l => l?.src). Same class of
silent-failure bug at the adapter layer.
Build layer¶
check-renderers.mjs (described above) is the build-time guard.
The pattern: anything that "silently swallows bad input" is a bug
even if we haven't hit it yet — apply the same audit shape upward
(matchers, schema-↔-component validation) when a layer comes into
focus.
3. Icon primitive — explicit static map, not import.meta.glob¶
Adopted. Icon imports live in
packages/components-v3/src/components/ui/icon/icon.astro as
explicit import statements per icon, mapped through LUCIDE and
SIMPLE records.
Alternatives considered¶
import.meta.glob("node_modules/lucide-static/icons/*.svg") —
the original implementation. Rejected.
What settled it¶
When the primitive is consumed via Vite alias from
packages/components-v3/, the glob pattern resolves relative to the
icon file — looking for packages/components-v3/src/components/ui/icon/node_modules/lucide-static/...,
which doesn't exist. The glob silently returns an empty set with no
build error. Every Icon name becomes "no match" → silent emit nothing
(prior behaviour) or loud placeholder (after the audit).
We hit this in production with arrow-right failing across every
ServiceGrid tile.
Static-map imports fail at build time when a name is misspelled or an import target is missing. The failure mode is loud.
LinkedIn isn't in simple-icons (licensing); the static map omits it
and the loud-fail placeholder fires instead. Acceptable for now —
fix is lucide-static → lucide swap, or a bundled custom SVG, when
LinkedIn icon support becomes worth the 15-minute decision.
Consequences¶
Positive¶
- Single source of truth for blocks. CMS edits to design have to land in the canonical, where the assembler will pick them up too.
- Adapters are auditable in one place — small, cohesive, no design logic.
- Every layer fails the same way. Operator pattern-recognises a red dashed placeholder regardless of whether it came from an unknown block type, an unresolved icon, or a missing image src.
- The build refuses to ship silent gaps. The 5 silent-fail primitives were sitting there since fulldev/ui import; the audit shape catches whole classes at once, not bug-by-bug over weeks.
Negative / risks¶
- Adapter drift. If a canonical's prop shape changes, every adapter that consumes it is a potential break. Mitigation: the adapter layer is small and the build-time guard catches missing renderers; type drift is the gap (see ADR-0004 follow-ups).
- Static-icon map maintenance. Adding a new icon requires a code change. This is a feature, not a bug — the change is one-line, and it makes "what icons are usable" enumerable. Does mean a future session has to look at the map before assuming an arbitrary lucide name works.
- Loud placeholder noise during real authoring. When an operator is mid-edit and a block is incomplete, the page renders dashed-red rectangles. This is by design but is uncomfortable in admin preview. May want to gate placeholder rendering on a "dev/preview" signal eventually.
When to revisit¶
- Drift between adapter and canonical prop shapes. If type errors start showing up at adapter sites, the gap (schema-↔-component type validation) is a real problem and needs ADR-0006.
- fulldev upstream gets unmaintained or pivots. Same trigger as ADR-0001 — fork upstream and keep going.
- Loud-fail placeholders create noise problems in CMS preview.
Add a
previewmode that softens placeholders, but never disables them entirely.
Followups¶
Shared theme emission — CMS reuses the assembler's emitGlobalCss (2026-05-12)¶
The CMS deployed worker and the per-site assembled builds now share one
theme-emission code path. Previously the CMS had an empty
apps/cms/src/styles/theme.css (template with all overrides
commented out) and a fixed tailwind.css that only imported Tailwind.
Canonical fulldev blocks rendered with their hardcoded fallback
colours — happened to be FCR-typical (cyan / yellow-green) so the
output looked approximately right by coincidence, but missing fonts,
the per-site service-accent tokens, and any deviation from garvanbay's
exact palette.
The followup adds:
apps/cms/scripts/emit-theme-css.mjs— readsbuilds/<domain>/theme.json(where<domain>comes fromseed.json#/meta/name) and writes the output oflib/assembler-fulldev/theme.js#emitGlobalCsstoapps/cms/src/styles/global.css. Runs as part ofprebuildalongsidecheck-renderers.- Base.astro now imports
global.css(generated) +theme.css(operator-override layer, still empty by default). The legacy--fcr-primary/--fcr-accent/--fcr-foregroundinline-style block in Base.astro is removed — body now reads the canonical--foreground/--background/--font-sanstokens thatglobal.csspopulates. TeamGrid.astroswitched fromvar(--fcr-primary, ...)tovar(--primary, ...)to match the canonical naming.lib/theme-extractor.jspatched to honourBUILDS_DIRenv var (consistent withseed-from-build.js); the hardcoded EC2-only output path is now a fallback.tw-animate-cssadded as a CMS dep —emitGlobalCssimports it.
Same canonical theme, two consumers, both work after. Phase 3
regression-check intent: any tweak to theme.js ripples to both the
assembler-emitted per-site builds AND the CMS-emitted global.css.
Multi-tenant deferral. Today's CMS deploy serves one domain
(garvanbay). When multiple FCR sites share a single CMS worker,
global.css must move from a build-time file to a runtime D1 lookup
(or Worker-level per-host static asset). Captured here so the
single-tenant scaffolding doesn't paint into a corner: the helper
contract (theme.json → CSS string) stays stable; only the storage
flips.
Path A enforcement — 9 hand-rolled adapters rewritten (2026-05-12)¶
When ADR-0004 was accepted on 2026-05-10, Path A (adapter-wraps-canonical)
was the stated pattern but only 10 of the 19 CMS adapters under
apps/cms/src/components/fcr-blocks/ followed it. The remaining 9 were
hand-rolled with their own inline <style> blocks and bespoke .fcr-*
class names — a parallel design surface that diverged visually from the
per-site assembled build (which uses the canonical fulldev blocks
directly via the assembler-fulldev pipeline).
The divergence was invisible until Slice 3 of ADR-0005 (image migration) landed — once images stopped 404'ing on the deployed CMS, the structural mismatch became the dominant visual signal. This followup slice rewrote all 9 hand-rolled adapters to delegate to canonical blocks:
| Adapter | Canonical target | Reason for picker |
|---|---|---|
Hero.astro |
hero-4 (with media) / hero-3 (text-only) |
Branch on imageUrl / videoUrl / slideshowUrls presence per ADR-0003 §"Hero" variant table. |
About.astro |
content-1 |
ADR-0003 §"About / Content". imagePosition: 'left' not honoured (canonical doesn't expose the prop) — known polish gap. |
CtaStrip.astro |
cta-wcp |
Custom variant — canonical accepts both scalar and object image, no further translation needed. |
FAQ.astro |
faqs-1 |
{question, answer} → {title, description}. showSearch: false is currently advisory — canonical always renders the input. |
BlogPosts.astro |
posts-wcp |
Per known-patterns "Blog Feed Pipeline". |
Checklist.astro |
checklist |
Multiline string items split on \n and mapped to { text } items. |
Header.astro |
header-wcp |
Matches assembler-fulldev's choice (per lib/assembler-fulldev/translate.js). Submenus not supported by CMS schema (single-level only). Superseded 2026-07-08 pt6: items gained a multiline subItems field ("Label | /url" per line, the footer-lines encoding) — fromHeader emits it from menus[].links, the adapter parses it back, and the canonical dropdown renders it. The CMS header is no longer single-level. |
Footer.astro |
footer-wcp |
First partnerLogos entry becomes the credit image; designCredit text drops if no logo present. |
TopBar.astro |
topbar-wcp |
CMS schema flattens contact fields → canonical groups them under contact: { phone, email }. Platforms outside the canonical's typed enum fall back to the generic link icon. |
The pattern after this slice: every CMS adapter is a thin (15–60 LOC)
file that imports a canonical block, translates Portable Text fields to
the canonical's prop shape, and delegates rendering. Inline <style>
blocks are absent except for one heading-wrapper micro-style in
TeamGrid.astro (canonical's slot context isn't heading-friendly).
Smoketest verified post-deploy. /, /contact, /smoketest,
/adapter-test all return HTTP 200 with zero fcr-{hero,about,cta-strip,
faq,checklist,blog-posts,header,footer,topbar} legacy class hits in the
HTML and 16–31 canonical block markers each. Image refs route through
Astro's /_image?href=...&f=webp pipeline (was raw <img src> before).
Known follow-up gaps (not regressions; pre-existing schema/data
shapes that the canonical doesn't fully express):
- About's imagePosition: 'left' renders with canonical's default
direction. Adding direction support is a canonical-side prop change.
- FAQ's showSearch: false is ignored. Same — needs a canonical prop.
- Header doesn't carry submenu nesting (CMS schema is single-level).
Closed 2026-07-08 pt6 — see the Header row note above (subItems).
D1 ↔ seed.json drift (orthogonal). The deployed CMS's home row
in D1 was seeded from a pre-Slice-3 snapshot with empty imageUrl.
The adapter correctly routes to hero-3 (text-only) given that data,
which is correct adapter behavior but visually diverges from the live
garvanbay homepage (which has a hero image). Re-seeding with the
current seed.json would restore the rich hero data; that's a re-seed
step, not an adapter concern.
Editor-surface followups (2026-07-04)¶
The WS-3 spike (editing through the deployed emdash admin) surfaced three gaps between "renders when seeded" and "an operator can author":
-
Native Portable Text types need renderers. The emdash editor lets an operator insert native blocks (paragraph/heading/quote/lists =
_type: "block";_type: "code";_type: "image"). The dispatcher only knewfcr.*, so any insert tripped the loud-fail "Missing renderer" banner on the live page. AddedProse.astro(block→ PortableText, empty skipped),CodeBlock.astro,ImageBlock.astro(native image, loud-fail if the asset has no URL), registered under non-fcr.*keys (check-renderers ignores them). Commits25112bc,5394c81. -
About adapter routed everything to
content-1(stacked). A side image rendered full-width above/below the text andimagePositionwas ignored. Fixed to mirror the assembler (lib/assembler-fulldev/translate.js): a block with a side image →content-2(SectionSplit, side-by-side) withreverse = (imagePosition === "left"); text-only →content-1.imagePositionis now honored and operator-editable. Commitffdf270. -
The CMS
<main>was not an@container.content-2/SectionSplituse a container query (@5xl:grid-cols-2) that only becomes two columns inside a sized@container. The per-site assembler provides this via itssection-providerwrapper; the CMS rendered<main><slot/></main>, so every split stayed single-column. Added@container/section-providertoBase.astro's<main>. Commit63beb5f.
Pattern: the canonical block library and the container primitives are shared with the assembler; the CMS must replicate the assembler's wrapper context (section-provider / @container), not just call the same blocks. See known-patterns "SectionSplit needs a container-query context".
Loud-fail scoping — structural absence is judged at the BLOCK, not the item (2026-07-13)¶
§2 says bad/missing input renders a visible placeholder, and the "Avatar-conditional" rule (known-patterns) already carves out structurally optional fields. This followup records the scope at which "structural" is decided, because getting it wrong is what produced 276 loud-fail placeholders on a site whose content was entirely correct.
Two distinct causes, both closed 2026-07-12/13, both of which looked like missing assets and were not:
- A shape-coercing emitter destroyed content (
USPBar: fromLogoStrip— a text-item matcher pointed at an image-item emitter, so{icon,text}became{src:''}). The placeholder was the symptom; the data loss was the bug. Fix: split the emitter by what the section actually carries. See known-patterns "One emitter, two item shapes". - A block rendered a media slot for cards that legitimately have none
(
services-1.astrorendered<TileMedia>unconditionally, so a live link grid — title + href, zero imagery — loud-failed on every card).
The rule that falls out, and that any new block must honour:
An item missing a field is loud when its siblings have it (a real gap an operator must see); it is structural when no item in the block has it (the design simply has no imagery there). So guard at the container: compute
hasMedia = items.some(i => i.image?.src)and omit the media slot entirely when false — never blanket-guard per item, which would silence the real gaps (exactly the short-circuit deliberately removed fromteam-grid.astroin §2).
Retaining the signal must be proven, not assumed: force a mixed grid through the live path (blank one image of three, confirm the placeholder fires, revert).
Corollary for diagnosis: a wall of img?/logo? placeholders invites the story "the
capture missed the images". Ask which matcher produced this block first — the
build's section-matchers.json sidecar answers it in one lookup, and it was the
correct answer three times running.
References¶
- ADR-0001 (fulldev as canonical foundation)
- ADR-0002 (primitive neutrality, the related fingerprint discussion)
- ADR-0003 (assembler-fulldev — same canonical, different consumer)
apps/cms/src/components/FcrBlocks.astro(dispatcher)apps/cms/src/components/MissingBlock.astro(block-layer placeholder)apps/cms/scripts/check-renderers.mjs(build-time guard)packages/components-v3/src/components/ui/icon/icon.astro(static-map Icon)- Memory:
feedback_fulldev_chosen.md - Session:
pipeline/session-2026-05-10-handover.md,pipeline/session-2026-05-12-handover.md(Path A enforcement)