ADR-036: Token + Theme Export via Bundle-Provided CSS Custom Properties Served From a Same-Origin Route
On this page
Status
Accepted 2026-06-11. Anchors Plan S — Multi-Jurisdiction Foundation Step 11; the implementation lands in Plan U (State Bundle Pattern) Step 8. The sibling of ADR-034 (terminology) — it mirrors that ADR’s bundle-contribution posture (bundle-embedded source, BFF-local materialization, boot fail-fast). Supersedes the disk-path home for theme content sketched in Contract 4 (the author-facing theme.toml schema is preserved; only its filesystem home is refined). Builds on ADR-032 §2.7 (boot activation + fail-fast), §4 + A11 (additive BundleContribution growth; theme is pre-materialized) and ADR-038 §3 (registry materialization) + §4 (field-sourcing / orphan rule).
Context
CRAIG’s multi-jurisdiction mission requires per-jurisdiction brand theming: a state’s deployment carries its own primary/accent colors, light + dark (and, where required, high-contrast) palettes, and agency branding. The design team’s Contract 4 specified this as a bundle-provided theme.toml declaring a named or custom palette, with hard rules: light + dark are both required, --accent is decoration only (never carries text contrast), and red is reserved exclusively for danger.
CRAIG’s craig-web BFF already serves CSS from services/craig-web/static/css/ and selects a per-jurisdiction theme via the CRAIG_WEB__THEME env var, which loads static/themes/<theme>/override.css (today an empty file for the only theme, georgia-orchard). Existing tokens are ~19 --color- custom properties defined once in :root (light-only), consumed by ~180 var(--color-) references across chrome.css/components.css/screens.css.
Two standing facts shape the design. First, CRAIG runs a strict Content-Security-Policy: craig-web emits style-src 'self' with no 'unsafe-inline' (Plan C F-018 Step 14 + issue #414; the literal policy is pinned by the WEB_BFF_CSP constant in crates/craig-test-lib/src/csp.rs and guarded against regression). An inline <style> block is therefore forbidden — per-jurisdiction theme tokens MUST be delivered as a same-origin stylesheet. Second, CRAIG is one deployment, one jurisdiction: the active state bundle (ADR-032 §2.7 CRAIG__ACTIVE_STATE_BUNDLES) is fixed at boot, so the theme is fixed at boot.
This ADR records the decision to deliver per-jurisdiction tokens as CSS custom properties from a generated same-origin route, sourced from the active bundle’s pre-materialized theme contribution, mirroring how ADR-034 delivers terminology.
Decision
1. Serve theme tokens from a generated same-origin GET /assets/theme.css route
craig-web exposes GET /assets/theme.css, a route handler returning Content-Type: text/css whose body is a CSS string generated at boot from the active bundle’s theme (§3-§4). It mirrors the existing dynamic-CSS precedent (serve_intake_public_css in services/craig-intake/src/ui.rs; the metrics_handler content-type pattern in craig-web). /assets/ is an unused path today; the route is mounted alongside /metrics and inherits the global CSP automatically (the policy is a top-level SetResponseHeaderLayer). Because the stylesheet is served from 'self', style-src 'self' admits it with no 'unsafe-inline' — this route is the required mechanism, not an optional one. A dynamic route (not a ServeDir file) is necessary precisely because the content is generated from the bundle contribution, not a file on disk.
In templates/base.html, the <link rel="stylesheet" href="/assets/theme.css"> replaces the existing /static/themes/{{ ctx.theme }}/override.css link, slotting after tokens.css (so bundle tokens win over the structural baseline) and before chrome.css/components.css/screens.css (so consumers see resolved values). tokens.css is retained for non-color structural tokens (font sizes, radii, gutters); the per-jurisdiction color palette moves to the generated theme.css.
2. ThemeContribution is a typed, fully-resolved, pre-materialized palette owned by the bundle crate
A new pre-materialized field is added to BundleContribution (ADR-032 §4):
// crates/craig-state-bundle
pub type TokenPair = (&'static str, &'static str); // ("--primary", "#103052")
pub struct ModeTokens { pub tokens: Vec<TokenPair> }
pub struct ThemeBranding { pub agency_name: &'static str, pub logo_path: Option<&'static str> }
pub struct Palette {
pub light: ModeTokens, // required
pub dark: ModeTokens, // required (Contract 4: light+dark both)
pub high_contrast: Option<ModeTokens>, // optional (state systems)
pub branding: ThemeBranding,
}
pub struct ThemeContribution { pub palette: Option<Palette> } // None ⇒ product default
A bundle selecting a named product palette references a built-in Palette (no parse in the jurisdiction crate); a bundle shipping a CUSTOM palette include_str!`s its own `theme.toml and validates it. Either way it resolves at construction and contributes a fully-resolved Palette — NOT raw TOML text. The value is PRE-MATERIALIZED per ADR-032 A11 / ADR-038 §3 (no BootContext resource is needed — a value, not a factory). The Palette and ThemeContribution types live in craig-state-bundle so the bundle author’s crate can construct them (ADR-038 §4 field-sourcing) — exactly where TerminologyContribution lives.
This is the one principled difference from ADR-034, and it is itself a consistency decision: ADR-034 contributes raw &'static str Fluent because craig-web already owns a Fluent parser, so handing it raw .ftl adds zero new parse path. CRAIG has no theme-TOML parser anywhere; pushing the parse into the bundle keeps the BFF a pure struct → CSS renderer (no toml dependency, no validation path in craig-web). In both axes the producer hands the consumer the most-resolved form the consumer can use without growing a parser — for Fluent the .ftl string, for theme the typed Palette.
Named product palettes (Simple Statehouse today; The Foundation and the Orchard set per the Theme Token Schema), and the product-default palette of §5, ship as built-in Palette values in craig-state-bundle. Plan U Step 8 resolved the deferred topology with BUILD-TIME codegen: craig-state-bundle’s `build.rs parses its theme/*.toml at build time and generates the palettes as &'static str Rust data, so toml/serde are BUILD-dependencies of craig-state-bundle and never enter its RUNTIME graph. Its non-theme consumers (craig-exchange, craig-reporting, which depend on it only for the partner registries) and the BFF (craig-web) therefore inherit NO runtime toml. This honors the contract this ADR fixes — the TOML-parse dependency must not leak into non-theme consumers — at the strongest level (build-time, not runtime). A jurisdiction selecting a named palette (e.g. craig-state-ga) needs no toml at all (it overrides only branding in Rust); only a jurisdiction shipping a CUSTOM palette parses theme.toml in its own bundle crate.
3. Theme content is bundle-embedded; refining Contract 4’s rulesets/ sketch
Theme source theme.toml lives in a bundle crate, not under a shared service tree: the canonical palettes' source lives in craig-state-bundle/theme/ (parsed by its build.rs — §2), and a jurisdiction shipping a CUSTOM palette embeds its own at crates/craig-state-<jurisdiction>/theme/theme.toml via include_str!. A jurisdiction that selects a named product palette (e.g. craig-state-ga) ships no theme.toml and overrides only branding in Rust. This refines the literal rulesets/<jurisdiction>/theme.toml path Contract 4 sketched — the same refinement ADR-034 §3 made to the umbrella’s locales/<jurisdiction>/ sketch — for the same reasons: ADR-038 §4 requires a contribution’s value to originate in a crate the bundle author owns (a built-in palette or an include_str! from the bundle crate satisfies this; a file under craig-web’s tree does not); ADR-032 A5 already moved per-jurisdiction content (mock routes) INTO the bundle/partner crates rather than a shared service; the BFF stays thin; and Plan U adds the `craig-web → bundle dependency anyway (Plan U Step 8, the state-ga feature). Contract 4’s author-facing TOML schema ([color.light]/[color.dark], [branding] agency_name/logo_gold) is preserved; only its filesystem home is refined.
4. Materialization is BFF-local; the generated CSS string IS the registry
There is no standalone ThemeRegistry type. At boot, craig-web resolves the active bundle(s) (ADR-032 §2.7 activation, implemented in Plan U Step 7), takes the active bundle’s ThemeContribution, validates it (§8), renders the Palette into one CSS string, and holds that Arc<str> in AppState (a sibling field to the terminology I18n table). The route serves it. The generated CSS string IS the materialized theme registry — the same rule ADR-038 §3 applies to the exchange registries and ADR-034 §4 applies to terminology (the consuming host materializes the axis it consumes; craig-web materializes theme because it renders the chrome). A parallel HashMap-backed registry would duplicate it for no consumer.
The materialization helper is fallible — materialize_theme_css(active: &ThemeContribution) → Result<Arc<str>, ThemeBootError> — and main ?-propagates it into its anyhow::Result<()>, so the §8 structural check can halt boot. craig-web has no theme-boot path today (it reads the CRAIG_WEB__THEME env var), so this is purely additive — the analogue of ADR-034 making I18n::load fallible. This makes Plan U Step 8 depend on Step 7 (activation must exist before theme can read the active bundle) — a within-Plan-U sequencing gate, identical to terminology’s. Under 1-deployment-1-jurisdiction exactly one bundle contributes a theme; if more than one is active (a test build), the last-activated wins (mirror ADR-034 §5).
Amendment (2026-06-25 — #710, epic &60; ADR-043). craig-web is no longer the only theme consumer: ADR-043 makes craig-intake (the stateless public edge) render the themed public report form for every deployment, so it too materializes /assets/theme.css from the active bundle. Three refinements to §2/§4 above, all consistency-preserving (the renderer is unchanged code; the §2 no-runtime-TOML invariant and the §8 boot fail-fast are preserved):
-
The renderer is SHARED, not BFF-private. The pure
&ThemeContribution → CSSrenderer —materialize_theme_cssplus its private helpers (render_palette_css,render_block,validate_palette) and theThemeBootErrortype — moves fromservices/craig-web/src/theme.rsintocrates/craig-state-bundle(its natural home besideThemeContribution), exposing onlymaterialize_theme_css+ThemeBootErroraspub(the crate’s#![deny(unreachable_pub)]gate keeps the helpers private). Both binaries call the identical function, so they emit byte-identical CSS; the §4 "the generated CSS string IS the registry" rule is unchanged — each host still holds its ownArc<str>. The generated-CSS banner comment becomes service-neutral (drops "by craig-web") so the bytes match. -
Materialization is HOST-local, not BFF-local. §4’s "BFF-local" is widened to "host-local": each consuming binary (
craig-web,craig-intake) resolves its own active bundle at boot, materializes its ownArc<str>, and serves its ownGET /assets/theme.css. Only the pure renderer is shared. -
The active-bundle RESOLVER stays per-binary (cycle constraint). The resolver wrapper (
candidate_bundles()+active_contribution(),services/craig-web/src/bundle.rs) does NOT move: it names the concrete bundle crates (craig_state_ga,craig_state_tx_stub), which depend oncraig-state-bundle— moving the resolver into the crate would make the crate depend on its own dependents (a cycle).resolve_active_bundleis already shared (activation.rs); each binary keeps a thin (~30-line) wrapper over it listing its own enabled concrete bundles.craig-intakegains an equivalentbundle.rs. This supersedes the program-brief sketch of "move the resolver into the crate" — see the consolidation plan § "The cycle constraint". Amendment (2026-07-25, 1072): the wrapper stays per-binary, but its ~30-line BODY is now single-sourced by thecraig_state_bundle::candidate_bundles!macro —[cfg(feature = …)]inside a macro expansion evaluates against the INVOKING crate’s features, so the contract crate still never names the concrete bundle crates as dependencies; the cycle constraint holds for a function move and the macro is the mechanism that sidesteps it. Registering a new bundle’s CODE is one macro edit, not a per-consumer sweep — the per-consumer Cargo wiring (thestate-<name>feature + optional dependency, per the feature-matrix contract) remains.
This also supersedes the "Out of scope" bullet below that deferred per-jurisdiction theming of craig-intake as future work: that work is now in scope and is exactly this amendment + ADR-043.
5. An empty contribution inherits the product-default palette
ThemeContribution::default() is palette: None — the cheap empty default ADR-032 §4 requires. When the active bundle’s palette is None, craig-web renders a built-in PRODUCT-DEFAULT Palette — a const in craig-state-bundle (§2), referenced by craig-web, not owned by the BFF — so /assets/theme.css always defines the design-team tokens and the alias layer (§7) always resolves. A Some(palette) is a bundle DECLARING a theme and must be structurally complete (§8). This reconciles ADR-032 §4’s cheap-empty-default rule with Contract 4’s "a bundle MUST specify a palette": None is inheritance, not a declaration, and is exempt from the structural gate exactly as ADR-034 §8 exempts an empty terminology contribution.
6. Light / dark / high-contrast are OS-driven CSS media queries, not a toggle
The generated theme.css emits a :root { … } block (light) plus @media (prefers-color-scheme: dark) { :root { … } } and, when the palette ships a high-contrast mode, @media (prefers-contrast: more) { :root { … } }. Mode selection follows the OS preference — pure CSS, zero JavaScript, CSP-clean by construction (a JS/cookie theme toggle would add script-src surface and per-user state; it is out of scope). On a browser without prefers-contrast support, high-contrast gracefully degrades to the light/dark palette (no breakage); a forced in-app high-contrast toggle is a later concern. Palette.light and Palette.dark are non-Option, so a light-only theme is unrepresentable — Contract 4’s "light + dark both required" is enforced by the type before boot validation runs.
Amendment (2026-06-23 — #641, #642). High-contrast is now IMPLEMENTED for the product palette (it was carried in the Palette type but never populated). Two refinements to the §6 sketch above:
-
HC is 2-axis. The 2026-06-20 design handoff supplies DISTINCT deepened values for light (dark-on-light) and dark (light-on-dark), so
Palette.high_contrastisOption<HighContrast { light, dark }>(not a singleModeTokens), and the renderer emits TWO blocks —@media (prefers-contrast: more) { :root { … } }(HC-light) and@media (prefers-contrast: more) and (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { … } }(HC-dark) — mirroring the base light→dark cascade. The TOML carries SPARSE per-mode overrides ([color.high_contrast.light]/[color.high_contrast.dark], only the deepened tokens) merged onto the base modes at build time, so HC inherits full token parity (boot-gated byvalidate_palette). The[data-contrast]attribute toggle stays REJECTED (Alternatives §) — HC is OS-prefers-contrast-driven, zero-JS. This also corrects the staletoken-schema.adocclaim that "absent, the app derives HC from light": absent HC, noprefers-contrastblock is emitted. -
--select-arrowis a URL-valued token (#642). A color inside aurl("data:…")can’t reference avar(), so the<select>dropdown-arrow SVG data-URI is itself a palette token (fill baked per mode) instead of a hardcoded data-URI in the static CSS — making the arrow adapt to light/dark and retiring its raw-hex-scanner carve-out (§8). The token’s fill now lives in the generatedtheme.css, which the static-stylesheet scanner does not cover.
7. Token migration via an alias layer; --body is the body-text token
The design-team token names (--primary, --link, --accent and palette-specific accents, --page, --body, the semantic triads) are the canonical source of truth, emitted by theme.css. The ~176 existing var(--color-*) references keep working through an alias layer the generator emits in theme.css — --color-link: var(--link), --color-body-text: var(--body), --color-page-bg: var(--page), … — declared once on the light :root; because the aliases are var() indirections, when a mode media query re-binds a canonical token the aliases follow automatically (late binding), so the alias layer is not repeated per mode. Legacy CSS needs zero edits during the grace period.
The migration direction is pinned: design-team tokens are canonical and primitive (real hex); --color- are deprecated aliases. The alias layer lives through Plan U execution and is removed when a mechanical sweep rewrites the references to the canonical names; the legacy→semantic mapping table is the one in the Theme Token Schema (as ADR-034 deferred its term- key mapping to Step 9). The canonical body-text token is --body (the role --color-body-text plays today, #38424b for light, WCAG-AA-audited against --page); the Theme Token Schema (locked 2026-06-14) defines it, and reserves the -ink SUFFIX for semantic-triad text-on-surface (--danger-ink, --warning-ink, …). This supersedes the standalone --ink this ADR originally named — Contract 4 / identity-and-palettes named --ink in the WCAG triplet without defining it; the schema settled the body-text role as --body.
tokens.css’s `--color- color definitions are removed as part of Step 8 (its structural tokens — font sizes, radii, gutters — are retained); the generated theme.css, loaded after tokens.css, owns the color tokens and the alias layer. The alias layer covers the var(--color-) references; it does NOT cover the ~25 hardcoded hex colors that appear directly (and as var(--color-x, #fff) fallbacks) in the component CSS — those are out of scope for this migration and are flagged by the build-time hardcoded-hex scanner (§8) for a later tokenization pass (a hardcoded color would otherwise ignore a jurisdiction’s dark palette).
8. Boot fail-fast validates structural coverage; aesthetics are a build-time lint
materialize_theme_css validates STRUCTURAL coverage at boot, BFF-local: a declared (Some) palette has the required semantic token set present in each mode (the ADR seeds this set with the known core — --primary, --body, --page, and the palette’s link/accent token; the Theme Token Schema finalizes the full list (locked 2026-06-14), see Open questions — so the gate is specified at the mechanism level plus a known minimum, not underspecified), light and dark declare the identical role set (a mode mismatch is a boot error), and a bundle that declared a theme actually specified a palette. A miss is a typed ThemeBootError and craig-web refuses to start — the same posture as ADR-034 §8’s missing-locale gate, same rationale (a silent miss surfaces as an unstyled or contrastless production UI discovered by an end user, not at the boundary). The check is BFF-local because craig-web is the theme consumer; services that never render templates must not gate boot on theme.
Everything aesthetic stays a BUILD-TIME lint, off the boot path: WCAG-AA contrast on --primary/--body/--page audited both light and dark, --accent-never-text, red-is-danger-only, and the hardcoded-hex scan — the xtask scanner the accessibility playbook already names. These are policy checks over palette values and source, knowable at build time; computing WCAG ratios on the boot path would add color math to startup for a property the build already proved. This mirrors ADR-034’s split between the boot bilingual-coverage gate and separate translation-quality work. The canonical required-token list is finalized in the Theme Token Schema (locked 2026-06-14; see Open questions).
9. The active bundle supersedes the env-selected theme; branding is carried, wiring deferred
The active StateBundle’s `ThemeContribution supersedes the static/themes/<theme>/override.css + CRAIG_WEBTHEME mechanism: /assets/theme.css (bundle-driven) replaces the override.css link, the empty georgia-orchard/override.css and the themes-dir override are retired, and CRAIG_WEBTHEME is no longer a palette selector — the active bundle is the selector, consistent with 1-deployment-1-jurisdiction. The theme config field itself is RETAINED for now (today logo_url() derives /static/themes/{theme}/logo.svg from it); only its palette-selection role is superseded here. Removing the field is gated on the branding wiring (below), so the logo fallback does not break in the window between retiring the palette role and wiring bundle branding — a deliberate decoupling to avoid a sequencing hazard. Because the bundle parses the whole theme.toml, ThemeBranding { agency_name, logo_path } rides along on the contribution at zero marginal cost. But WIRING branding into the page (a served logo asset, agency_name into PageContext, superseding the CRAIG_WEB__BRANDING_AGENCY / /static/themes/{theme}/logo.svg env-driven branding) is deferred to Plan U follow-through: the logo is an asset-serving + img-src concern, categorically different from the token/CSS export this ADR fixes. The fields are captured now; their wiring lands narrowly later (see Out of scope).
10. Theme and terminology are structurally identical Phase-2 axes
By construction, theme (Plan U Step 8) and terminology (Plan U Step 9) are the same shape: bundle-embedded source via include_str! → a pre-materialized *Contribution field whose value type lives in craig-state-bundle → materialized BFF-local by the consuming host (craig-web) into the form the runtime already uses (the I18n table for terminology; the generated CSS string for theme) → boot fail-fast on required structural coverage → an empty contribution inherits the product default → activation-gated on Step 7. The only principled difference is the contributed value form (raw &'static str for Fluent vs a typed Palette for theme), itself a consistency decision (§2). The two Phase-2 BFF axes are indistinguishable in architecture.
Consequences
Positive
-
CSP-clean by construction: a same-origin generated stylesheet needs no
'unsafe-inline', so theming does not weaken the strict CSP. -
Theme is structurally identical to terminology and every other
BundleContributionaxis — a bundle is self-contained, its palette compiles in under the same Cargo feature, and it is discoverable through the one aggregate. -
The BFF stays thin: no
tomldependency or parse/validate path incraig-web(the bundle pre-resolves);craig-webonly rendersstruct → CSS. -
translate()-grade runtime cost: the CSS is generated once at boot and served from anArc<str>; per-request cost is a refcount bump. -
Light + dark are enforced by the type; structural completeness is enforced at boot; the migration is mechanical and additive behind the alias layer.
Negative
-
craig-webgains a dependency oncraig-state-bundle(and the active bundle crates). It had none. -
A theme/token change requires a rebuild (no runtime catalog reload).
-
The alias layer is a temporary double-definition window; a missed sweep would leave dead
--color-*aliases. -
The structural boot check can pass while a palette is aesthetically wrong (low contrast) — by design; that is the build-time lint’s job.
-
Hard-caching
/assets/theme.csscould serve stale tokens to a long-lived client across a deploy.
Mitigations
-
The new dependency is the same shape
craig-exchangealready carries tocraig-state-ga, and Plan U adds it regardless via thestate-gaCargo feature + CI matrix — a sanctioned, already-planned edge (identical to ADR-034). -
No-reload matches every other bundle axis; runtime reload is deferred per ADR-032.
-
The alias-layer removal is tracked in Plan U with a lint that the alias block is gone once the ~180 references are migrated.
-
The aesthetic gate is the
xtaskbuild-time scanner (§8), which fails CI before a low-contrast palette can ship. -
A conservative
Cache-Control(and/or a deploy-scoped version) bounds staleness; a deploy rotates the whole binary anyway.
Open questions
Deferred to the implementation (Plan U Step 8), not blocking acceptance:
-
The canonical required-token list (Contract 4’s "20 semantic IDs") and `--ink’s exact per-palette values — RESOLVED 2026-06-14: the fixed 28-token set and both baseline palettes' light/dark values are locked in the Theme Token Schema.
-
Cache strategy for
/assets/theme.css(a fixedmax-agevs a hash-basedETag/ deploy-versioned path) — a tuning detail;Content-Type: text/cssis the only firm requirement. -
The branding wiring (served logo asset,
agency_name→PageContext, retiring the branding env vars) — captured inThemeBrandingnow, wired in a narrow Plan U follow-through. -
A per-user persisted theme override (beyond OS
prefers-color-scheme) — a later cookie/preference-store feature, out of scope here.
Alternatives considered
-
Carry raw
theme.toml&'static strintocraig-weband parse there (literal ADR-034 mirror). Rejected:craig-webhas no theme-TOML parser, so this thickens the BFF with a parse + named-palette-resolution + validation path and atomldependency. Pre-resolving in the bundle keeps the BFF a pure renderer and is the truer mirror of "pre-materialized, BFF thin" (§2). -
Inline
<style>block in the page head. Rejected: forbidden bystyle-src 'self'(no'unsafe-inline'); the entire reason the route exists. -
A JavaScript / cookie theme toggle. Rejected: adds
script-srcsurface and per-user state; OS-preference media queries are CSP-clean and match the design docs' default; a persisted per-user override is deferred. -
theme.tomlon disk undercraig-web(the literal Contract 4 / themes-dir sketch). Rejected: violates ADR-038 §4 field-sourcing and the ADR-032 A5 bundles-own-their-content precedent; thickens the BFF. Thecraig-web → bundledependency it avoids is added by Plan U anyway. -
A
themedefault that is a named baseline palette rather thanNone. Rejected in favor ofOption<Palette>withNone→ product-default, which is the cleaner mirror of ADR-034’s empty contribution and keeps "declare vs inherit" distinct. -
Keep
CRAIG_WEB__THEMEas a separate selector. Rejected: redundant with bundle activation under 1-deployment-1-jurisdiction. -
A standalone
ThemeRegistrytype materialized like the partner registries. Rejected: the generated CSS string already is the materialized form for the only consumer (the<link>); a parallel map would duplicate it.
Out of scope
-
Wiring the bundle logo asset +
agency_nameinto the page (the env-driven branding stays until a narrow Plan U follow-through; this ADR carries the fields only). -
A per-user persisted theme override beyond OS
prefers-color-scheme. -
The mechanical rewrite of the ~180
var(--color-*)references (the alias layer makes it deferrable; the sweep is Plan U Step 8 detail). -
The
xtaskbuild-time contrast / red / hardcoded-hex scanner implementation (Plan U / accessibility tooling; this ADR fixes only that the aesthetic checks live there, off the boot path). -
Per-jurisdiction theming of
craig-intake(the public intake form + status checker — a separate binary with its own CSP that serves a fixedintake-public.csswith a hardcoded palette). Theme tokens here are consumed bycraig-webonly; extending per-jurisdiction theming to intake is future work. — SUPERSEDED 2026-06-25 by the §4 amendment + ADR-043: the edge now materializes/assets/theme.cssfrom its active bundle likecraig-web. -
Runtime theme reload.
Related decisions
-
ADR-034: Per-Jurisdiction Terminology Resolution via Bundle-Overlaid Fluent Catalogs — the mirrored sibling Phase-2 BFF axis; §10 states the structural identity.
-
ADR-032: Multi-Jurisdiction Partner Registry and Transport Abstraction — §2.7 boot activation + fail-fast (this ADR’s §8 mirrors it); §4 + A11 additive
BundleContributiongrowth, theme pre-materialized; A5 bundles-own-their-content precedent. -
ADR-038: Trait-Object & Registry Patterns — §3 registry materialization (BFF-local here) + §4 field-sourcing / orphan rule (drives the bundle-embedded decision).
-
ADR-031: Nursery Lint Triage and Promotion Pattern — ADR format mirror.
-
Plan S — Multi-Jurisdiction Foundation — umbrella; this ADR is Step 11; implementation is Plan U Step 8.
-
Design engineering contracts — Contract 4 (token + theme; the
theme.tomlfilesystem home is refined here, the schema + invariants preserved). -
Theme Token Schema — the authoritative
theme.tomltoken contract this ADR anchors: the fixed 28-token role set, both baseline palettes' full light/dark values, the legacy--color-*migration map, and the generated-CSS shape (locked 2026-06-14; resolves this ADR’s "canonical required-token list" open question). -
Identity and palettes — the design-language framing (brand tokens, typography, red-is-danger); the full token names + values live in the Theme Token Schema above. This ADR defines
--ink(§7). -
Accessibility playbook — the contrast /
--accent/ red-is-danger invariants the build-time lint enforces (§8). -
ADR-033 (anticipated) — Plugin Manifest + Render Contract — plugin chrome renders server-side within
craig-web(same-origin), so it consumes the CSS custom properties served here automatically; it is not a separate theme consumer.