ADRs
ADR 0038 — Brand layer persistence
  • 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

ColumnTypeNotes
idtext PKbsd-<8-char nano>
project_idtext NOT NULL, FK → project.id ON DELETE CASCADE
slottext NOT NULLprimary | secondary | accent-1 … (a small fixed enum; checked server-side, not at the DB layer so we can grow it without migrations)
hextext NOT NULL#rrggbb — the seed value the ramp is generated from
labeltext NULLoptional designer-facing name (e.g. "Sunset orange")
created_attimestamptz NOT NULL DEFAULT now()
updated_attimestamptz 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

ColumnTypeNotes
idtext PKbnd-<8-char nano>
project_idtext NOT NULL, FK → project.id ON DELETE CASCADE
semantic_idtext NOT NULLthe slot the binding fills (e.g. interactive.primary, status.danger)
seed_slottext NOT NULLwhich brand_seed.slot row to source from (primary / secondary / …)
steptext NOT NULLwhich ramp step the binding picks (500, 600, …)
created_attimestamptz NOT NULL DEFAULT now()
updated_attimestamptz 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

ColumnTypeNotes
idtext PKbft-<8-char nano>
project_idtext NOT NULL, FK → project.id ON DELETE CASCADE
familytext NOT NULLDTCG-shape brand.font.family.$value[0] name the designer chose
file_nametext NULLoriginal upload filename (for the audit)
byte_sizeinteger NOT NULLupload size — used for the per-project font budget
data_base64text NOT NULLthe font bytes, base64-encoded. Same shape as project_font.data_base64 (the existing system-font table from B6). Postgres blob, no R2
licensetext NOT NULLowned | ofl | commercial | embedded | other (enum checked server-side)
license_reftext NOT NULLdesigner's reference (≥12 chars) — receipt URL, EULA pointer, free-form
user_idtext NULL, FK → user.id ON DELETE SET NULLauthor of the attestation
created_attimestamptz 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 on seed_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.ts typings (BrandSeed, BrandBinding, BrandFont, BrandLicense, BrandSeedSlot) + fetch / put / delete functions
  • No demo wiring change — SemanticRebindDemo, CustomFontDemo, HctRampDemo continue 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_font rows. License audit is owned by the file presence, not by historic stamps.
  • Do not FK brand_binding.seed_slot to a row in brand_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_font already 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.