- Date: 2026-06-10
- Status: Accepted
- Phase / Feature: Sorgente baseline
- Builds on: ADR 0052 (shared DB), 0053 (Foundation rules), 0054 (Library surface), 0055 (layout overrides + Tap), 0056 (Studio layout tree)
Context
After ADR 0056 the Tokens sub-tab gained Studio render, drag & drop, Tweak container controls, Cmd+C/V, and Library navigation. Typography / Spacing / Radii / Effects / Brand / Library / History sub-tabs did not. Capability fragmentation crept in: a feature shipped on one surface didn't reach the others.
A first instinct was to lock every Sorgente sub-tab into a single <SorgenteSubtab> wrapper. That collapses UI variety into one mold — Brand is a CRUD form over seeds + bindings, Library is a directory of cards, History is an append-only log. Their layouts are data-driven, not maintainer-arranged. Forcing them into the same shell makes the wrapper drift into edge cases.
The correct invariant is at the component level, not the surface level. Surfaces stay free to render whatever they want; the leaves they render obey one contract so Inspector tools work everywhere.
Decision
§1 — The contract
Any React component in apps/web/src/ that renders an interactive or editable Sorgente value carries the following attributes on its root DOM element:
| Attribute | When required | Purpose |
|---|---|---|
data-arno-component | Always | Industry-name label (PascalCase). Tap groups by this; Add-to-library uses it as authoritative source. |
data-arno-role | Always | Optional discriminator inside a component (color-chip vs value-chip for TokenChip). |
data-arno-key | Always | Unique-within-role identity (token id, entry id, route path). |
data-arno-token-id | When the component represents a Sorgente token | Tweak picks the token toolbar (Apply / Reset / Delete / Go to component / Add to library). |
data-arno-box-id + data-arno-box="row" | "column" | When the component is a Studio container (row/column) | Tweak picks the container controls (direction / gap / padding / align / justify). |
data-arno-scope | When the component is a page / template root | Detector for scope.ts (chrome vs project vs preview). |
A component renders zero of these only if it is purely decorative (icon glyph, divider, spacer).
§2 — Surfaces stay free
Sub-tabs render whatever UI fits their data:
- Tokens uses Studio tree + drag & drop (free layout)
- Library renders cards + sub-tabs by atomic-design kind (data-driven list)
- Brand renders two CRUD forms (seed + binding)
- History renders an append-only log
- Storybook embeds an iframe
- Components future surfaces may use grids / tables / canvases
None of this is mandated by the contract. The contract only mandates that the leaves these surfaces render — TokenChip, ColorSpecimen, SeedField, log line, etc. — carry the right attributes. Once they do, Tweak / Tap / Add-to-library / Hash-scroll work uniformly across every surface.
§3 — Studio participation is opt-in per surface
A surface chooses whether it wants Studio drag & drop:
- Tokens — yes (maintainer rearranges groups).
- Library / Brand / History — no, because order is dictated by data, not by the maintainer.
When a surface opts in, it wraps its layout tree in useStudioTree + StudioRenderer (as Tokens does today). When it opts out, it renders directly and the contract still keeps Tweak / Tap working on individual leaves.
§4 — Empty state
A shared <EmptyState> component lives in apps/web/src/components/empty-state.tsx. Surfaces use it when their list / tree / form has no rows. Uniform message shape ("No X yet — do Y to add some.") + uniform visual treatment. Surfaces that want to override the visual still use the component (override via props) so the data attributes stay consistent.
§5 — Lint
scripts/check-sorgente-contract.ts (warn-only at first; flip strict per ADR 0053 §9 pattern) scans Sorgente component sources for:
- JSX that renders an editable value (text
<input>whosevalue=traces to Sorgente state) withoutdata-arno-token-idon an ancestor in the same file. - JSX with
style={{ background: \var(--...)` }}(a swatch) withoutdata-arno-token-id` on an ancestor in the same file. - Components named
*Specimen/*Chip/*Tile/*Swatchwithoutdata-arno-componenton the root.
Run as pnpm sorgente:contract. Surface-by-surface migration possible because each violation is local.
§6 — Capability matrix lives at component level
docs/sorgente/_index.md replaces its old surface × capability table with a component × Inspector-hook table:
| Component | data-arno-component | -token-id | -box-id | Surfaces that use it |
|---|---|---|---|---|
| TokenChip | ✓ | ✓ | n/a | Tokens, Library, Hierarchy demo |
| ColorSpecimen | ✓ | ✓ | n/a | Tokens.Colors, Library |
| SeedField | ✓ | ✓ | n/a | Brand |
| Studio row/column | n/a | n/a | ✓ | Tokens (Studio render) |
| History log line | ✓ | n/a | n/a | History |
| AddToLibraryDialog row | ✓ | n/a | n/a | Tweak |
| ... |
A new feature ships by extending this table — not by walking every surface.
§7 — Migration
Existing components are audited in this PR. Specifically: SeedField + BindingField in Brand panel; entry card in Library tab; row in History tab. Each gets a one-line attribute set so the contract holds. No surface UI is restructured.
Anti-patterns explicit
- Do not introduce a
<SorgenteSubtab>umbrella that wraps every surface. UI variety stays. - Do not force Studio render on data-driven surfaces (Brand, Library, History). Drag & drop without maintainer-driven order is friction.
- Do not hand-roll Tweak / Tap detection per surface. Every detection path keys off
data-arno-*and that's the single contract. - Do not keep adding capabilities to Tokens only. New feature touching Inspector or Studio means the contract surface grows, and every component participating in it sees it for free.
Open
- Capability matrix population: do this in a follow-up PR. The contract is enough to ship lint + audit now.
- Type-level enforcement (a
<InspectorAware>Higher-Order Component that enforces attributes at compile time) is deferred — the lint script is cheaper to maintain.