Source: ddx-documentation/adr/0009-variant-api.md
- **Status:** Accepted (2026-10-23) - **Binds:** G6.2, north star (one inventory, instantiation + override props), invariants 4, 5, 6 - **Research:** [RQ5](/en/docs/research/rq5-frameworks) (cva pick) · **Structure reference (read only):** HMS `PageContainer.tsx` + `PageContainer.spec.md` variant selector shape ## Context HMS had 11 page-chrome approaches and 61 `PageContainer-exempt` comments with 9+ reasons (`routes.md` I1). Each component invented its own prop names for look, state and size. Devs cannot pick from one inventory if every entry speaks a different prop language. ## Decision ### 1. Four style axes, same names everywhere (built with `cva`) | Prop | Meaning | Example values | |---|---|---| | `variant` | structural / visual flavour of the component | PortalPage: `standard` · `workspace` · `section` · `focus` · `agentic` · `fullbleed` | | `mode` | behavioural or data state | `view` · `edit` · `create` · `readonly` · `loading` · `empty` · `error` (subset per component) | | `size` | scale of the component itself | `sm` · `md` · `lg` | | `density` | spacing rhythm, from settings `ui.density` by default | `compact` · `comfortable` · `spacious` (superseded: values are now `compact` · `cosy` · `large`, [ADR 0012](/en/docs/adr/0012-density-names)) | - Variant classes map only to semantic utilities and `--token-*` values (invariant 4). No raw colour, px, rem or ms in a variant definition. - `density` defaults from `UnifiedSettingsProvider` (was `SettingsProvider`, ADR 0014 addendum); a prop overrides it for one instance. - `mode` never changes the variant; it changes behaviour and state visuals. - A component that has no meaning for an axis does not declare it (no dead props). ### 2. Inventory props = `{ instantiation, overrides }` Every inventory entry (`@/inventory`, task 022) exposes: ```ts type InventoryProps<I, O> = { instantiation: I; // required, typed: what this instance IS (entity, columns, i18n namespace, data source key) overrides?: Partial<O>; // optional, typed: per-instance deviations (variant, mode, size, density, slots, labels) }; ``` - `instantiation` is data and wiring. `overrides` is look and local behaviour. The axes of §1 live in `O`. - Strings in either are i18n **keys**, never text (Cardinal #23). - Pages compose only inventory entries with these two props (invariant 5). A page that needs a deviation not expressible in `overrides` extends the entry's `O` type — it does not fork the component (invariant 6). - `SitePage*` and `PortalPage*` mirrors share the same `I`/`O` types (BRIEF quality bar). ### 3. The role gate is not a variant `PortalPage` gates the role once (invariant 14, HMS I14 anti-pattern). Role is never a prop axis; layouts resolve org + session only. ## Rejected alternative - **tailwind-variants** (RQ5): rejected — a second variant dialect beside shadcn's cva. - **Free-form props per component** (HMS): rejected — the source of I1 / I13 drift. - **Exempt comments** for components that do not fit: rejected — extend `O` instead. ## Consequences - One docs template per entry: instantiation table, overrides table, axes, gallery link (G10.2). - The inventory registry spec can check every entry declares `instantiation` and that site/portal mirrors share types. ## Check ```sh ! rg -n 'PageContainer-exempt|tailwind-variants' ddx-web/src rg -n 'cva\(' ddx-web/src/components | head ```
PageContainer.tsx + PageContainer.spec.md variant selector shapeHMS had 11 page-chrome approaches and 61 PageContainer-exempt comments with 9+ reasons
(routes.md I1). Each component invented its own prop names for look, state and size. Devs
cannot pick from one inventory if every entry speaks a different prop language.
cva)--token-* values (invariant 4). No
raw colour, px, rem or ms in a variant definition.density defaults from UnifiedSettingsProvider (was SettingsProvider, ADR 0014 addendum); a prop overrides it for one instance.mode never changes the variant; it changes behaviour and state visuals.{ instantiation, overrides }Every inventory entry (@/inventory, task 022) exposes:
instantiation is data and wiring. overrides is look and local behaviour. The axes of §1
live in O.overrides extends the entry's O type — it does not fork the
component (invariant 6).SitePage* and PortalPage* mirrors share the same I/O types (BRIEF quality bar).PortalPage gates the role once (invariant 14, HMS I14 anti-pattern). Role is never a prop
axis; layouts resolve org + session only.
O instead.instantiation and that
site/portal mirrors share types.adr/
deploy/
research/
type InventoryProps<I, O> = { instantiation: I; // required, typed: what this instance IS (entity, columns, i18n namespace, data source key) overrides?: Partial<O>; // optional, typed: per-instance deviations (variant, mode, size, density, slots, labels)};! rg -n 'PageContainer-exempt|tailwind-variants' ddx-web/srcrg -n 'cva\(' ddx-web/src/components | head