- Date: 2026-06-08
- Status: Accepted
- Phase / Feature: Sorgente architecture
- Builds on: ADR 0036 (Foundation v1), ADR 0038 (brand persistence), ADR 0045 (composite refs), ADR 0048 (Constructor/Limiter framing), ADR 0052 (Sorgente shared-DB)
- Supersedes (partially): ADR 0052 §1 storage shape — adds
tier+value_typecolumns + ref values + composite shapes onto the samesorgente.*namespace. Shared-DB architecture stays.
Context
ADR 0052 stood up sorgente.* as a flat key-value store: 98 v9-synth CSS variables, each row a scalar string. That shape held while the goal was "get edits flowing live across envs." It does not match how Foundation organizes a design system. Foundation rules — 3-tier cascade, refs, composite tokens as live references, brand layer that drives semantic roles through HCT ramps, theme split, WCAG-locked publish — are the substrate that lets a design system stay coherent as it grows. Sorgente, the dev surface for ARNO's own UI, has to live by those same rules or it accumulates the exact pathologies Foundation's rules exist to prevent: primitives leak into components, semantic edits do not cascade, composites drift from their source values, components hard-code raw hex.
This ADR re-fits Sorgente's content (the v9-synth tokens) onto Foundation's rules without touching Foundation itself. The two design systems remain separate packages with separate audiences (Foundation for greenfield user projects; Sorgente for ARNO's own chrome), but they share an architecture.
Foundation as a code artifact is not modified by this ADR.
Decision
§1 — 3-tier cascade in Sorgente
Every sorgente.token_override row carries a tier enum: primitive | semantic | component.
- Primitive — raw value, no refs. The palette. v9-synth examples:
mint.500 = #4dd99e,neutral.0 = #060908,space.16 = 16px,radius.md = 16px. Designer rarely edits these directly; they are the floor. - Semantic — role-named, value is a ref to a primitive (
{mint.500}) or a composite object built from refs. v9-synth examples:text.primary,interactive.primary,surface.base,border.subtle. This is where most maintainer edits land. - Component — scoped to one component, value is a ref to a semantic (
{interactive.primary.default}). v9-synth currently has zero of these; the namespace is reserved and surfaced in the editor as an empty section the maintainer fills as components stabilize.
Rule (enforced by Limiter, §6): component-tier rows MUST reference semantic-tier or another component-tier row. They MUST NOT reference primitive directly, MUST NOT carry a literal value.
§2 — DTCG $type metadata
Every row carries value_type: color | dimension | typography | shadow | fontFamily | fontWeight | cubicBezier | duration | number | string. Same enum Foundation's DTCG JSON files declare. Drives:
- Editor field shape (color picker for
color, dimension input with unit fordimension, composite editor fortypography/shadow, dropdown forfontWeight). - Composite serializer dispatch (typography → CSS
fontshorthand; shadow → CSS shadow string; fontFamily array → comma-joined with quoting). - WCAG validator gating (runs only for
colorpairs). - Limiter rules (
colorsemantic must ref acolorprimitive, not adimension).
§3 — Refs syntax {token.id}
Same shape as Foundation DTCG ({color.neutral.900}). A value can be:
- Scalar literal:
"#4dd99e","16px","normal". - Ref string:
"{mint.500}". Resolved recursively at read time. Composites can hold refs in any leaf field. - Composite object:
{ fontFamily: "{font.family.sans}", fontSize: "{font.size.300}", ... }. Each leaf is independently scalar OR ref.
A resolveTokenValue(id) function walks the chain, returns the literal terminal value OR inherit with a non-blocking warning if the chain dead-ends. Same fallback rule as Foundation (ADR 0045 §5).
§4 — Composite tokens are live refs (ADR 0045 applied)
Editing a primitive immediately repaints every composite that references it. Composite is stored with refs intact ({font.size.700}), never with the resolved literal — keeps the cascade contract.
Escape hatch: per-composite "Detach" action persists the resolved value verbatim, lifting the composite out of the cascade. UI marks it (detached) with a 'Re-attach' affordance.
Composite kinds in Sorgente v1: typography, shadow. Border/transition/gradient added when the v9-synth content needs them.
§5 — Brand cascade
brand_seed + brand_binding tables already in place from ADR 0052. The cascade pattern from Foundation (ADR 0038 §5) applies in full:
brand_seed.primary.hex → user-edited HCT seed (e.g. mint #4dd99e)
↓ generateRamp(hex) runtime-derived (NOT persisted)
13-step ramp mint.0, mint.50, ..., mint.500, ..., mint.1000
↑ refs (text strings)
brand_binding semantic role → seed_slot + step
'interactive.primary' → primary/500
↓ resolves at read time
semantic rows get the brand hex text.link.default → {color of primary/500}
↓ cascade
component rows reflect component.button.primary.bg → text.link.default's hexv9-synth's mint is the primary brand seed; violet is the secondary. Their existing sparse ramps (mint, mint-soft, mint-dim) are replaced by HCT-derived 13-step ramps at migration time; the three legacy points are computed equivalents within the new ramp.
§6 — Limiter (Sorgente edition)
ADR 0048's Limiter applies, with the maintainer-can-override-via-Claude affordance the project uses everywhere:
| Rule | When blocked |
|---|---|
| Component-tier rows referencing a primitive directly | Editor save rejects, surfaces the would-be-correct semantic ref |
| Semantic-tier rows holding a literal scalar instead of a ref | Editor save rejects, suggests "wrap as {primitive.id}" |
| Color WCAG pair failing AA threshold on resolved (fg, bg) | Apply lands but row is flagged; publish (export to globals.css) blocks unless wcag_override row exists with reason ≥ 12 chars |
Free hex in brand_seed slot when value passes through HCT | Allowed (Sorgente trusts maintainer); Foundation's "HCT only" Limiter not enforced — irrelevant for Arno self-hosting |
| Type mismatch on refs (color semantic pointing at a dimension primitive) | Editor save rejects |
Mentor (inline explanations) is NOT built in Sorgente — maintainer asks Claude when stuck. Limiter messages are short, factual; they do not pedagogize.
Override path: maintainer who wants past any Limiter rule says "override" in chat → Claude makes the edit through direct DB write or a dedicated bypass endpoint. Limiter is a guardrail, not a wall.
§7 — Theme: dark-only for v1
v9-synth is dark-only and stays so. Schema supports a future theme column but Sorgente v1 emits only :root { ... } (no .theme-dark scope, no light parallel). Adding light is a separate ADR when the second theme is actually needed.
§8 — WCAG validation
ADR 0036 §7 contract, applied to Sorgente:
- Server-side on every color Apply: compute contrast of every (fg, bg) pair this token participates in. Result persisted on a
sorgente.wcag_resulttable (per (token_id_a, token_id_b), updated_at). - Editor surfaces the result inline (badge: passes AA / fails AA / no pair to check).
- Publish (the
release (prod)regenerate step) blocks the build if any activecolortoken participates in a failing pair AND no livesorgente.wcag_overriderow exists for that pair. - Override row carries reason (text ≥ 12 chars), author, timestamp. Same shape Foundation uses (ADR 0037).
- Algorithm: WCAG 2.1 AA — normal text ≥ 4.5:1, large ≥ 3:1, UI ≥ 3:1. Threshold determined by
kindfield on the token (defaults: text.* = normal-text, icon.* = ui, others = ui).
§9 — All ARNO chrome consumes Sorgente tokens
apps/web/src/components/*.css and inline style={{...}} props across the app surface — no raw hex / px / shadow strings. Every value either uses a var(--<token-id-dashed>) reference or pulls from useSorgenteState for dynamic React renders. This is the closing rule that makes Sorgente meaningful: Foundation has Tier-3 component tokens because the whole component layer is token-bound; Sorgente earns the same property by audit + refactor of every component file.
The audit pass is large but mechanical: each #xxxxxx / rgba(...) / hardcoded 12px / explicit shadow gets replaced with the matching Sorgente token's CSS variable. Tokens that don't yet exist for a hardcoded value get added as component-tier rows during the same pass.
§10 — Storage shape changes (additive)
ALTER TABLE sorgente.token_override
ADD COLUMN tier text NOT NULL DEFAULT 'semantic', -- 'primitive' | 'semantic' | 'component'
ADD COLUMN value_type text NOT NULL DEFAULT 'string'; -- DTCG $type
-- override_value jsonb stays as-is; now accepts scalar, ref string, composite object.
CREATE TABLE sorgente.wcag_result (
id text PRIMARY KEY,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
fg_token_id text NOT NULL,
bg_token_id text NOT NULL,
ratio numeric(6, 3) NOT NULL,
kind text NOT NULL, -- 'normal-text' | 'large-text' | 'ui'
required numeric(4, 1) NOT NULL,
passes_aa boolean NOT NULL,
computed_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, fg_token_id, bg_token_id)
);
-- sorgente.wcag_override already exists from the original 0052 schema; kept.Defaults on the existing 98 rows: tier inferred by name pattern + value shape (one-shot migration runs at the same release), value_type inferred similarly (#xxxxxx → color, ends with px → dimension, otherwise string).
§11 — Migration of v9-synth tokens (one-shot)
A reclassifier script (tools/reclassify-v9-synth/run.ts) walks every existing sorgente.token_override row:
- Detect
value_typefrom value shape: hex/rgba/hsl → color;Npx/Nrem/N%→ dimension; etc. - Detect
tierfrom token-id namespace:mint.*,violet.*,neutral.*,pri.*,space.*,radius.*,font.size.*,font.weight.*,motion.*,breakpoint.*→ primitivesurface.*,border.*,text.*,icon.*,interactive.*,bg.*,shadow.*,status.*→ semanticcomponent.*→ component- Legacy aliases (
bg,fg,muted,accent,font-stack) → rewritten as semantic with refs to the new IDs.
- Detect literal → ref conversions: where v9-synth had
--bg: var(--surface-base), rewrite totier=semantic, value{surface.base}(Sorgente ref syntax) instead of the legacy CSSvar(...)pass-through. - Brand seeds: insert
brand_seedrows forprimary=mint(#4dd99e)+secondary=violet(#8b7dff). Insertbrand_bindingrows for every semantic role that references mint or violet, pointing at the appropriate ramp step.
Script is idempotent — re-running on already-classified rows is a no-op. Logs every classification decision for review.
§12 — Resolver pipeline (server + client)
A single resolver lives in packages/foundation (no, wait — Foundation untouched) — lives in a NEW shared module under apps/web/src/lib/sorgente-resolver.ts (imported by overlay AND by tools/export-arno-sorgente for the build-time bake).
Inputs: full SorgenteState (all rows of every type + brand seeds + bindings).
Output: Map<tokenId, resolvedCssValue> — final CSS-ready string per token id.
Steps:
- Build a graph of all rows (token_id → value).
- For each token, resolve recursively:
- Scalar literal → return as-is.
- Ref
{token.id}→ recurse on target. Cycle detection (depth > 8 = dead-end). - Composite object → resolve each leaf, then dispatch to composite serializer (typography → CSS
fontshorthand, shadow → CSS shadow string).
- Brand bindings: for each binding row, resolve to ramp step hex via
generateRamp(brand_seed.hex). Inject as the resolved value for that semantic id, OVERRIDING any literal value the row might have carried. - Dead-end refs (missing target, cycle): result is
inherit+ console warning entry attached to the state.
§13 — Editor UX shape
/app/sorgente Tokens tab grows three sub-tabs:
- Primitives — list filtered to
tier='primitive'. Color primitives grouped by ramp (mint, violet, neutral, pri, status). Editing here cascades down. - Semantic — list filtered to
tier='semantic'. Ref-aware field (the value field accepts{token.id}autocomplete OR literal; toggle button switches). Detach action visible on composites that currently hold refs. - Component — list filtered to
tier='component'. Same ref-aware field but onlysemanticandcomponenttokens autocomplete in the dropdown (Limiter §6 enforces).
Each row carries: resolved value preview (the actual CSS string after refs walk), ref-source breadcrumb (text.link → mint.500 → #4dd99e), Apply / Reset / Delete + Detach (composites only).
The Brand tab stays as-is (seeds + bindings). Apply log stays as-is.
A new WCAG sub-tab under Tokens (or a dedicated tab — TBD during build) shows the live WCAG result list: passing pairs, failing pairs, overridden pairs. Overrides created from this surface.
§14 — Sequence (one cohesive feat → main flow)
- Migration 20260608130000_sorgente_tier_value_type.sql + 20260608130100_sorgente_wcag_result.sql + journal entries.
- Schema.ts adds
tier+value_typecolumns +wcag_resulttable. tools/reclassify-v9-synth/run.ts+ workflow to dispatch.apps/web/src/lib/sorgente-resolver.ts(shared with export). Composite serializer extracted.- WCAG validator (off-thread Web Worker) on Apply path.
- Editor sub-tabs + composite editor + ref-aware field.
- Component refactor pass: every hardcoded value across
apps/web/src/components/**/*.cssand inline styles → token ref. - Tests for resolver, serializer, WCAG, Limiter.
- Sync schema + migrations + workflow to main; squash-merge feat → main.
- Apply migrations to dev + test Neon. Apply also to prod Neon (this is the prod release — Sorgente needs to be live on
arnomake.comend-to-end per the maintainer's flow). - Set
SORGENTE_DATABASE_URLon prod worker (NOT — per ADR 0052 §2, prod must NOT carry the secret. Prod's chrome runs on static CSS baked byrelease (prod). No live Sorgente endpoint on prod). Verify endpoint returns 503 on prod. release (prod)workflow run — regeneratesglobals.cssfrom the now-tier'd Sorgente state + builds + ships.- Test stand auto-deploy on push to feat already covered by
deploy-test.yml.
Anti-patterns explicit
- Do not import Foundation's tokens into Sorgente at runtime. Sorgente has its own primitives and semantics. The two design systems are siblings, not a base + override.
- Do not introduce "Reset to Foundation default" affordance in Sorgente. Sorgente IS the canon for Arno; nothing to reset to.
- Do not build Mentor inline-explainer surface in Sorgente. Maintainer pings Claude when stuck.
- Do not allow a component-tier token to reference a primitive directly. Always through semantic.
- Do not persist resolved composite values for non-detached composites. Stay with refs; resolve at read time.
- Do not light-theme Sorgente yet. Open ADR when the need surfaces.
Open questions
- Component tokens scope — Component tier sub-tab ships empty in v1; populated by maintainer over time. No predefined list — Foundation's
component.button.*namespace was authored ahead of consumers; Sorgente waits for actual demand. - Multi-shadow / gradient composites — supported in serializer once they exist in v9-synth. Today neither does.
- Audit lint for component CSS files — the §9 "no hardcoded values" rule needs a linter to enforce going forward. Stub in this ADR; lint script lands separately.