- Date: 2026-06-06
- Status: Accepted
- Phase / Feature: Foundation v1 · B3 (brand-import ritual) backend closer
- Closes: The persistence gap for ADR 0036 §5 / Foundation T12 family (T12.1 logo palette, T12.2 HCT ramp, T12.5 drag-drop rebind, T12.6 BYOF)
- Builds on: ADR 0036 §5 (brand-import ritual), ADR 0037 (WCAG override storage — same single-project, soft-delete-only pattern)
Context
Foundation v1 Phase B shipped the brand-import patterns as Storybook surfaces backed by in-memory state:
- HCT ramp generator (T12.2) takes a seed hex, deterministically produces 13 Foundation-aligned tones with WCAG metadata. No persistence — refresh and the ramp is gone.
- Logo palette extractor (T12.1) surfaces dominant colors from an uploaded image. No persistence.
- Semantic rebind (T12.5) drags a ramp step onto a semantic slot. The binding evaporates on reload.
- BYOF font + license attestation (T12.6) registers a DTCG record into a local ledger. No backend, no audit, no actual font shipped to the project.
Until persistence lands, every brand-import demo is a toy. The in-app editor surface that will consume these flows needs a stable store to read from and write to.
This ADR fixes the storage shape, the read-time derivation strategy, and the API surface so the demos can wire up to a real backend the moment the editor surface lands. The frontend wiring itself stays in Storybook stubs — same pattern ADR 0037 used for WCAG overrides.
Decision
§1 — What we persist vs. what we re-derive
The HCT ramp is deterministic from (seed, hue, chroma). Once we store the seed hex, the 13 steps come back identically every read by calling generateRamp(seed). We therefore persist ONLY the seed — not the 13 step rows. This keeps the schema small, eliminates an entire class of "the persisted ramp drifted from the deterministic recompute" bugs, and lets us evolve the ramp algorithm (Phase B+ chroma boost / dark-mode ramp variant) without rewriting historic rows.
Logo palette extraction output is NOT persisted. The palette is a transient intermediate the designer scans for a seed pick. Persisting per-pixel quantizer output would re-explode the schema for zero downstream value — once the designer chose a swatch, that swatch becomes the brand seed (table below), the rest of the palette is throwaway.
What we DO persist: brand seed, semantic→ramp-step bindings, custom fonts with license attestation.
§2 — Tables
brand_seed — one or more named HCT seeds per project
| Column | Type | Notes |
|---|---|---|
id | text PK | bsd-<8-char nano> |
project_id | text NOT NULL, FK → project.id ON DELETE CASCADE | |
slot | text NOT NULL | primary | secondary | accent-1 … (a small fixed enum; checked server-side, not at the DB layer so we can grow it without migrations) |
hex | text NOT NULL | #rrggbb — the seed value the ramp is generated from |
label | text NULL | optional designer-facing name (e.g. "Sunset orange") |
created_at | timestamptz NOT NULL DEFAULT now() | |
updated_at | timestamptz NOT NULL DEFAULT now() |
Unique index (project_id, slot) — one seed per slot per project. Re-uploading "primary" rewrites the row in place via PUT; the table stays small.
brand_binding — semantic-token → brand-ramp-step bindings
| Column | Type | Notes |
|---|---|---|
id | text PK | bnd-<8-char nano> |
project_id | text NOT NULL, FK → project.id ON DELETE CASCADE | |
semantic_id | text NOT NULL | the slot the binding fills (e.g. interactive.primary, status.danger) |
seed_slot | text NOT NULL | which brand_seed.slot row to source from (primary / secondary / …) |
step | text NOT NULL | which ramp step the binding picks (500, 600, …) |
created_at | timestamptz NOT NULL DEFAULT now() | |
updated_at | timestamptz NOT NULL DEFAULT now() |
Unique index (project_id, semantic_id) — one binding per semantic slot. Drag-drop replaces the row in place.
Note we keep (seed_slot, step) denormalized as text instead of FK'ing to a ramp-step row. The ramp steps are deterministically derived from the seed (§1); there is no brand_ramp_step table to FK to. If the designer later changes the seed hex for primary, every brand_binding row keyed on seed_slot='primary' automatically resolves to the new color at read time — exactly the cascade behavior ADR 0036 §7 promises.
brand_font — BYOF uploads with license attestation
| Column | Type | Notes |
|---|---|---|
id | text PK | bft-<8-char nano> |
project_id | text NOT NULL, FK → project.id ON DELETE CASCADE | |
family | text NOT NULL | DTCG-shape brand.font.family.$value[0] name the designer chose |
file_name | text NULL | original upload filename (for the audit) |
byte_size | integer NOT NULL | upload size — used for the per-project font budget |
data_base64 | text NOT NULL | the font bytes, base64-encoded. Same shape as project_font.data_base64 (the existing system-font table from B6). Postgres blob, no R2 |
license | text NOT NULL | owned | ofl | commercial | embedded | other (enum checked server-side) |
license_ref | text NOT NULL | designer's reference (≥12 chars) — receipt URL, EULA pointer, free-form |
user_id | text NULL, FK → user.id ON DELETE SET NULL | author of the attestation |
created_at | timestamptz NOT NULL DEFAULT now() |
Unique index (project_id, family) — one font per family per project.
Note: no soft-delete here. A removed brand font is a hard DELETE — distinct from wcag_override because there's no "audit trail of decisions" to preserve. The font file either ships with the project or it does not. If a license dispute arrives later, the absence of the row is sufficient evidence.
§3 — API surface
All endpoints under requireOwnedProject. Hono module: apps/api/src/brand.ts.
Seeds
PUT /api/v1/projects/:projectId/brand/seeds/:slot— body{ hex, label? }. Upserts the seed row for that slot. Returns the row.GET /api/v1/projects/:projectId/brand/seeds— returns the array of all seed rows for the project.DELETE /api/v1/projects/:projectId/brand/seeds/:slot— removes the seed. Bindings keyed onseed_slot=<slot>are NOT cascaded (they become "unbound" at read time so the UI surfaces them clearly).
Bindings
PUT /api/v1/projects/:projectId/brand/bindings/:semanticId— body{ seedSlot, step }. Upserts the binding for that semantic. Returns the row.GET /api/v1/projects/:projectId/brand/bindings— returns the array of all bindings.DELETE /api/v1/projects/:projectId/brand/bindings/:semanticId— clears the binding.
Fonts
POST /api/v1/projects/:projectId/brand/fonts— body{ family, fileName, byteSize, dataBase64, license, licenseRef }. Inserts a new font. Returns the row.GET /api/v1/projects/:projectId/brand/fonts— returns the array.DELETE /api/v1/projects/:projectId/brand/fonts/:fontId— removes the row. Hard delete (§2 rationale).
§4 — Frontend integration scope (this ADR)
Same scope discipline as ADR 0037 — the Storybook demos stay in-memory until the in-app token editor surface lands. This ADR delivers:
- Schema + migration + API
api-client.tstypings (BrandSeed,BrandBinding,BrandFont,BrandLicense,BrandSeedSlot) + fetch / put / delete functions- No demo wiring change —
SemanticRebindDemo,CustomFontDemo,HctRampDemocontinue to manage state locally
When the editor surface arrives it picks up the functions from api-client.ts and the demos either move there or get rewritten as Foundation-side React components that read the API directly.
§5 — Read-time ramp resolution
The frontend pattern when rendering brand-aware components:
const seeds = await fetchBrandSeeds(projectId); // ~6 rows
const bindings = await fetchBrandBindings(projectId); // ~5–25 rows
const ramps = new Map(seeds.map(s => [s.slot, generateRamp(s.hex)]));
const resolved = new Map<string, string>(); // semantic → hex
for (const b of bindings) {
const ramp = ramps.get(b.seedSlot);
const step = ramp?.find(s => s.step === b.step);
if (step) resolved.set(b.semanticId, step.hex);
}This is the "rule-locked cascade" of ADR 0035 §3 made concrete: change brand_seed.primary.hex, the entire ramp re-derives client-side, every binding routes through the new step, every component repaints. No server round-trip per step. No persisted ramp rows that could drift.
Anti-patterns explicit
- Do not persist the 13 ramp steps. Re-derive from the seed at read time. Persisting them invites drift and ramp-algorithm-change migrations that touch every brand row.
- Do not keep logo-palette quantizer output in the schema. The palette is a transient picker; the seed is the commitment.
- Do not soft-delete
brand_fontrows. License audit is owned by the file presence, not by historic stamps. - Do not FK
brand_binding.seed_slotto a row inbrand_seed. Keep it as text. An unbound binding (designer deleted the seed) is a surfaceable UI state, not a schema violation. - Do not put font data on R2 / external blob storage at this scale. Postgres holds it the same way
project_fontalready does — single source of truth, cascade-deletes with the project, no second auth surface.
Open questions / parking
- Multi-mode bindings (dark vs. light ramp pair per semantic). Phase B+ — for v1 every binding is mode-independent and the designer's responsibility to verify.
- Ramp step preference per slot (e.g. "primary should always sit at step 500 or darker on white"). Currently a UI hint inside the SemanticRebindDemo. Persisting it per-tenant would require an additional table or a constants column on
brand_seed. Defer until a customer asks. - Sharing / forking a brand kit across projects. Cross-project read pattern is non-trivial — single-project for v1.