ADRs
ADR 0053 — Sorgente architecture onto Foundation rules
  • 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_type columns + ref values + composite shapes onto the same sorgente.* 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 for dimension, composite editor for typography/shadow, dropdown for fontWeight).
  • Composite serializer dispatch (typography → CSS font shorthand; shadow → CSS shadow string; fontFamily array → comma-joined with quoting).
  • WCAG validator gating (runs only for color pairs).
  • Limiter rules (color semantic must ref a color primitive, not a dimension).

§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 hex

v9-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:

RuleWhen blocked
Component-tier rows referencing a primitive directlyEditor save rejects, surfaces the would-be-correct semantic ref
Semantic-tier rows holding a literal scalar instead of a refEditor 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 HCTAllowed (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_result table (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 active color token participates in a failing pair AND no live sorgente.wcag_override row 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 kind field 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:

  1. Detect value_type from value shape: hex/rgba/hsl → color; Npx/Nrem/N% → dimension; etc.
  2. Detect tier from token-id namespace:
    • mint.*, violet.*, neutral.*, pri.*, space.*, radius.*, font.size.*, font.weight.*, motion.*, breakpoint.*primitive
    • surface.*, border.*, text.*, icon.*, interactive.*, bg.*, shadow.*, status.*semantic
    • component.*component
    • Legacy aliases (bg, fg, muted, accent, font-stack) → rewritten as semantic with refs to the new IDs.
  3. Detect literal → ref conversions: where v9-synth had --bg: var(--surface-base), rewrite to tier=semantic, value {surface.base} (Sorgente ref syntax) instead of the legacy CSS var(...) pass-through.
  4. Brand seeds: insert brand_seed rows for primary=mint(#4dd99e) + secondary=violet(#8b7dff). Insert brand_binding rows 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:

  1. Build a graph of all rows (token_id → value).
  2. 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 font shorthand, shadow → CSS shadow string).
  3. 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.
  4. 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 only semantic and component tokens 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)

  1. Migration 20260608130000_sorgente_tier_value_type.sql + 20260608130100_sorgente_wcag_result.sql + journal entries.
  2. Schema.ts adds tier + value_type columns + wcag_result table.
  3. tools/reclassify-v9-synth/run.ts + workflow to dispatch.
  4. apps/web/src/lib/sorgente-resolver.ts (shared with export). Composite serializer extracted.
  5. WCAG validator (off-thread Web Worker) on Apply path.
  6. Editor sub-tabs + composite editor + ref-aware field.
  7. Component refactor pass: every hardcoded value across apps/web/src/components/**/*.css and inline styles → token ref.
  8. Tests for resolver, serializer, WCAG, Limiter.
  9. Sync schema + migrations + workflow to main; squash-merge feat → main.
  10. Apply migrations to dev + test Neon. Apply also to prod Neon (this is the prod release — Sorgente needs to be live on arnomake.com end-to-end per the maintainer's flow).
  11. Set SORGENTE_DATABASE_URL on prod worker (NOT — per ADR 0052 §2, prod must NOT carry the secret. Prod's chrome runs on static CSS baked by release (prod). No live Sorgente endpoint on prod). Verify endpoint returns 503 on prod.
  12. release (prod) workflow run — regenerates globals.css from the now-tier'd Sorgente state + builds + ships.
  13. 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.