
# Component Styles

> `design.components` restyles every instance of an engine component type — parts, variants and states — under a focus-ring floor that outranks both your classes and the shipped recipe.

`props.className` restyles **one** component. `design.components` restyles **every instance of a type**.

The difference matters more than it sounds. Before this key, an app that wanted square corners on every button repeated the same class list at every call site — and the failure mode is not an error message. It is the one site somebody missed, found later by a customer.

```yaml
design:
  components:
    button:
      parts:
        root: rounded-none tracking-tight
```

Every button in the app, in one line.

## Where it sits in the cascade

Four layers, applied in this order — later wins a same-property conflict:

1. **Sovrium's recipe** — the shipped default for the type.
2. **`design.components`** — this key. Your app-wide answer.
3. **`props.className`** — the one instance. Beats your own app-wide rule, which is what makes a one-off override possible without an escape hatch.
4. **The floor** — a small, non-negotiable set, applied last.

## Keyed by component type, not by your component names

The keys are **engine** component types — the ones Sovrium itself draws: `button`, `table`, `dialog`, `input`, and every other type the kit ships. Your own reusable templates under the top-level `components[]` key are not styled from here; you already control those end to end, and what they carry instead is `guidance`.

**The key set is closed.** A typo — `buton:` where you meant `button:` — is refused at boot, naming the key. An open map would accept it, tell you the config is valid, and serve an app that ignored every class you wrote under it, with nothing anywhere saying so.

Two types are excluded, each for the same underlying reason — there is no Sovrium-owned element to attach a class to. `customHTML` renders markup you supply, and `command-palette` emits no visible markup on the server: a config block and its runtime, with the overlay built in the browser on the first invocation.

## What a type entry holds

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `parts` | object |  |  | Classes applied to each named part of the component |
| `variants` | object |  |  | Per-part classes that apply only in the named variant |
| `replace` | boolean |  |  | Drop Sovrium's recipe for this type rather than layering on top of it. The accessibility floor still applies. |

### `states`

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `states` | object |  |  | Per-part classes that apply in one interaction state, written without the state prefix |
| `states.hover` | object |  |  | Classes applied while the pointer is over the component. |
| `states.focus` | object |  |  | Classes applied while the component holds keyboard focus, however it was reached. |
| `states.focusVisible` | object |  |  | Classes applied while the component holds keyboard focus and the browser judges a focus ring warranted — typically after keyboard navigation, not after a click. |
| `states.active` | object |  |  | Classes applied while the component is being pressed. |
| `states.disabled` | object |  |  | Classes applied while the component refuses interaction. |
| `states.open` | object |  |  | Classes applied while the component is expanded, such as an open menu or dialog trigger. |
| `states.selected` | object |  |  | Classes applied while the component is the chosen one among its siblings. |
| `states.checked` | object |  |  | Classes applied while a checkbox, radio or switch is on. |
| `states.invalid` | object |  |  | Classes applied while the value the component holds has been refused. |

All four are optional, and every leaf is a Tailwind class-list string. Within one part they layer in declaration order — `parts`, then the active `variants` entry, then any matching `states` entry — so a state class is later than a variant class and wins a conflict between them.

### `parts`

Every component has a `root`. A composite one names its inner elements too: a `table` has a `header`, a `row`, a `cell`.

```yaml
design:
  components:
    table:
      parts:
        root: border-2
        header: uppercase tracking-wide
        cell: font-mono
```

Which parts a type has is Sovrium's to define — a part is an element the renderer owns — and a name that is not one of them styles nothing.

**Unlike a type key, a part name is not refused.** Part and variant names are an open set: `headr:` decodes cleanly, boots cleanly, and paints nothing. The type key can be closed because Sovrium knows every type; a part name cannot, because the parts a type owns are an implementation detail that moves with the renderer. If a block seems to do nothing, a mistyped part is the first thing to check — and the design-system console names, per part, which layer each rendered class came from.

### `variants`

The variant vocabulary is per type: a `button` has seven, a `divider` has none.

```yaml
design:
  components:
    button:
      variants:
        destructive: { root: border-2 }
        ghost: { root: underline-offset-4 }
```

### `states`

**Write these without the state prefix.** Sovrium adds it.

```yaml
design:
  components:
    button:
      states:
        hover: { root: bg-neutral-800 }
        disabled: { root: opacity-40 }
```

Writing `hover: { root: 'hover:bg-neutral-800' }` is refused, for a concrete reason rather than a stylistic one: it would emit `hover:hover:bg-neutral-800`, which generates no CSS at all. You would have a valid config and no hover style. Other prefixes stay legal and are combined with the state — `md:bg-neutral-800` under `hover` becomes `hover:md:bg-neutral-800`.

`focus` and `focusVisible` are both in the set rather than collapsed into one, because they are different: `focus` fires when a script or a mouse moves focus; `focusVisible` only on the keyboard path.

**A state Sovrium computes rather than the browser** — `loading`, `pressed` — is not expressible here. Those have no CSS state to attach to; the renderer decides them. Mixing the two would mean half your entries applying through the stylesheet and half through code, with no way to tell from the config which kind you had written.

### `replace`

By default your classes are **merged over** the recipe: `p-8` beats the recipe's `p-4`, and everything you did not mention is inherited. `replace: true` drops the recipe instead — the escape hatch for a default that is wrong in **kind** rather than in degree. It does **not** drop the accessibility floor.

## Legal class lists, and the ones that are not

Every leaf is validated at boot. What it refuses is narrow and deliberate: inside an arbitrary value, the constructs that would let a class list reach out of the stylesheet — `url(`, `image-set(`, `attr(`, `expression(` and `@import`.

Arbitrary values themselves stay legal (`bg-[oklch(0.7_0.1_250)]`, `text-(length:--sv-density-text)`), and so does an empty string. The rule is not "no arbitrary values"; it is "a class list styles, it does not fetch". An image belongs in `imagery`, not smuggled into a background.

## What you cannot override

One thing here is a **floor** — classes applied after yours, so the guarantee holds whatever you write. The rest are not classes at all, and could not be changed from a class list in the first place.

**The focus ring.** A `button`, an `input` and a `textarea` keep a visible `focus-visible` ring; a `link` keeps its `focus-visible` underline. `ring-0` is a legal class list and the single most damaging one you can ship — a keyboard user loses track of where they are — so it is accepted, applied, and then overruled. Three qualifications, each worth knowing before you rely on it:

- **It covers those four types, and only the `root` part.** Nothing else carries a floor.
- **It is armed by your declaration.** An app that declares no entry for a type has no floor on it either. The floor guards against your override; where there is no override there is nothing to guard.
- **`props.className` still wins today.** The floor outranks `design.components`. A `focus-visible:ring-0` written on a single instance is a known gap, not a guarantee working as intended.

Beyond the floor: `aria-*` attributes and roles are not styling, and a class list is not the place to change what a component **is**; the names of the role tokens cannot move, because every recipe reads them (retuning a role's **value** is what `colorRoles` is for); the parts a type is made of belong to the renderer; and the Sovrium element mark is a trademark.

**Seeing which layer won.** `GET /api/admin/design-system/provenance?type=button&part=root` returns the resolved class list with the chain that produced it — one entry per contributing layer, with the floor entry marked locked and carrying the reason. A layer that contributed nothing is omitted, so the chain's length is itself readable.

## Ramps and role values

A palette is not a bag of hex values. It is usually a small number of **ramps** — ordered lightness ladders — plus a set of **roles** pointing into them.

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `50` | string |  |  | A colour literal, or a reference to another ramp step |
| `100` | string |  |  | A colour literal, or a reference to another ramp step |
| `200` | string |  |  | A colour literal, or a reference to another ramp step |
| `300` | string |  |  | A colour literal, or a reference to another ramp step |
| `400` | string |  |  | A colour literal, or a reference to another ramp step |
| `500` | string |  |  | A colour literal, or a reference to another ramp step |
| `600` | string |  |  | A colour literal, or a reference to another ramp step |
| `700` | string |  |  | A colour literal, or a reference to another ramp step |
| `800` | string |  |  | A colour literal, or a reference to another ramp step |
| `900` | string |  |  | A colour literal, or a reference to another ramp step |
| `950` | string |  |  | A colour literal, or a reference to another ramp step |

Declare only the steps you use. A ramp step may reference another ramp's step by name.

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `usage` | string |  |  | What this colour is for, and what it must not be used for |
| `pairsWith` | string |  |  | Name of the token this colour is designed to sit against (usually its foreground companion). May name a platform role token the app never redeclared. |
| `value` | string |  |  | The ramp step this role follows, or a colour literal |
| `dark` | string |  |  | The ramp step this role follows, or a colour literal |

A role's `value` says what it resolves to; `dark` says what it becomes under the dark scheme, and omitting it means "the same in both". A role that declares a value **defines** the token, so it does not need to already exist in `design.colors`; a role that only carries prose still documents a token declared there.

```yaml
design:
  ramps:
    neutral:
      '50': 'oklch(0.985 0 0)'
      '500': 'oklch(0.56 0 0)'
      '950': 'oklch(0.14 0 0)'
    info:
      '50': neutral-50
      '500': neutral-500
  colorRoles:
    background:
      value: neutral-50
      dark: neutral-950
      usage: The page ground. Never a fill on a control.
```

A reference that resolves to nothing is refused at boot, listing the ramps that do exist — an unresolved one would reach the browser as a variable nothing defines, and the surface would paint its initial value in silence.

**`oklch()` yes, `var()` no.** Colour values accept hex, `rgb()`, `hsl()` and `oklch()`. Sovrium's own ramps are written in `oklch`, the only widely supported space in which a ramp can be retuned by lightness without the hue drifting underneath. `var()` and `color-mix()` are refused because they are **references**, not values: they resolve against the browser's cascade, so nothing reading your config can know what colour they name — not the contrast checker, not the token export, not an agent reading your design system. When you want a token to follow another, say so with a reference the schema understands.

## Behaviour

### Author Class Overrides, Class-List Validation and Density

- A declared `className` beats the recipe for the same CSS property, and the recipe still applies otherwise
- A `className` carrying `url(` inside an arbitrary value is refused at boot, naming the token
- A `design.density` ladder boots and serves; a `byZone` key naming no declared zone is refused
- An author overrides a recipe class, declares a density, and is refused for both otherwise-silent mistakes

### Per-Engine-Type Component Styling, Colour Ramps and Numeric Scales

- An empty `design` renders exactly the body that no `design` renders, and a declared style renders a different one
- A declared class reaches the component in the SSR markup and survives the island's hydration
- The accessibility floor beats an operator's `focus-visible:ring-0`, while their other classes still apply
- An unknown component type and a doubled state prefix are refused at boot, naming the offender
- `replace: true` drops the recipe surface; omitting it layers over the recipe
- `oklch()` reaches the stylesheet; a `var()` colour is refused with a message naming ADR-032
- Ramps, role values and the numeric ladders boot; an unresolved ramp reference is refused, listing the declared ramps
- The accessibility floor covers a focusable part that is not the component root, while the operator's other part classes still apply
- `replace: true` drops the recipe from a non-root part without dropping that part's focus ring
- An operator restyles every button, cannot remove the focus ring, and is named for every otherwise-silent mistake

### Data scenes render consistently against v1

- populated-data-table scene renders v1-styled paginated table with 10 rows + status pills + priority pills + pagination indicator in light + dark
- calendar-with-events scene renders v1-styled 7-day week grid with 14 events distributed across day columns + kind-tinted event chips in light + dark
- kanban-board scene renders v1-styled 3-column board (To do · In progress · Done) with 5 cards per column + status pill footers in light + dark
- dashboard scene renders v1-styled 4-KPI tile row + 12-month chart (utility-class height ladder) + recent-activity feed in light + dark
- gallery-grid scene renders v1-styled 4-column product grid (12 tiles) with theme-aware tone placeholders + title + price + ID footer in light + dark
- timeline-with-events scene renders v1-styled 8-entry vertical timeline with day + time + kind glyph + duration in light + dark
- User can navigate to each `/scenes/data/{name}` page and see the canonical v1 rendering in both light and dark modes

### Forms scenes render consistently against v1

- Contact-form scene renders v1-styled name input, email input, subject select, message textarea, and primary/secondary action row in light + dark
- Settings-form scene renders v1-styled profile, preferences, and notifications sections separated by dividers, with grouped fields and checkbox rows in light + dark
- Multi-step-form scene renders v1-styled 3-step indicator strip (complete + current + upcoming states) and an active-step form body with back/skip/continue actions in light + dark
- Search-filter-form scene renders v1-styled search input with ⌘K hint, status filter pills (active + inactive), sort selector, and active-filter summary row in light + dark
- User can navigate to each `/scenes/forms/{name}` page and see the canonical v1 rendering in both light and dark modes

### Layout scenes render consistently against v1

- auth-layout scene renders v1-styled centered card with branding + email/password fields + submit + forgot-password link in light + dark
- empty-state scene renders v1-styled dashed-border surface with icon placeholder + heading + body + CTA in light + dark
- error-page scene renders v1-styled 404 layout with `text-error-solid` status code + heading + body + primary/secondary actions in light + dark
- app-shell scene renders v1-styled master composition (sidebar with Dashboard active + header bar + main content with 2x2 KPI grid) in light + dark
- User can navigate to each `/scenes/layouts/{name}` page and see the canonical v1 rendering in both light and dark modes

### Navigation scenes render consistently against v1

- sidebar-app scene renders v1-styled vertical rail with branding + 5 nav items (Dashboard active via `bg-primary`) + user footer in light + dark
- header-with-search scene renders v1-styled top app-bar with logo + horizontal nav + search input + user actions in light + dark
- breadcrumb-deep scene renders v1-styled 5-level trail with chevron separators (final crumb foreground role token) in light + dark
- tab-navigation scene renders v1-styled 5 tabs with mixed badges (primary/info/warning tones, Overview active via underline) in light + dark
- User can navigate to each `/scenes/navigation/{name}` page and see the canonical v1 rendering in both light and dark modes

### The prebuilt component library exists and looks right against canonical v1 defaults

- Buttons cluster renders the primary/secondary/ghost variants × sm/base/lg sizes plus disabled + loading states in light + dark
- Inputs cluster renders text/email/number/password/search variants plus default/error/disabled/readonly states in light + dark
- Selects cluster renders the closed-state select trigger, searchable-select trigger, and plain-select trigger in light + dark
- Toggles cluster renders checkbox states (unchecked/checked/indeterminate/disabled) + radio group + switch on/off + toggle + toggle-group
- Numeric cluster renders the single slider, range slider, and number-input with stepper buttons in light + dark
- Date cluster renders the closed-state date picker triggers (start + end) in light + dark
- Overlays cluster renders dialog + alert-dialog + drawer + popover + tooltip + menu surfaces as separate sub-containers in light + dark
- Disclosure cluster renders the tabs (3 tabs, "Activity" active) and accordion (3 items, item 2 expanded) surfaces in light + dark
- Feedback cluster renders the 5-variant badge row + 4 alert variants (info/success/warning/error) + skeleton + presence + comment-count
- Data displays cluster renders table (header + 3 rows + 1 selected) + kanban (2 cols × 2 cards) + simple-bar chart as sub-containers
- Layout cluster renders card + divider + 3×2 grid + max-width container with vertical scroll-area in light + dark
- Forms cluster renders auth-form (sign-in) + crud-form (3 fields with Cancel/Create footer) + file-upload (dashed dropzone + file row)
- Navigation cluster renders search-list + page-scoped search-input (header input with ⌘K hint) + nav-menu (3 items, Dashboard active) in light + dark
- Typography-in-chrome cluster renders h1/h2/h3 hierarchy + paragraph + inline code + code block + Source Serif blockquote + ul + ol
- UI Kit page renders end-to-end as a single scrollable document — all 14 clusters compose without cross-cluster visual interference
- User can navigate to `/ui-kit` and see every cluster rendered against canonical v1 (no theme override) in both light and dark modes

### Component Base Styles And Variants

- A rendered button keeps the `btn-*` token its variant was derived from
- A design colour reaches every button that uses it, with no per-component config
- `.btn-icon` makes a button as wide as it is tall regardless of its label
- The `input, select, textarea` rule paints a form field that carries no class of its own
- A page-level input is painted by its recipe instead, because utilities outrank components
- `.form-page` draws the standalone form shell — centred and bounded
- `.form-page > form` draws the card the fields sit in
- `.form-title` and `.form-description` carry the standalone form's type hierarchy
- The description tightens under the title on a standalone page only
- `.form-group-label` rules a field group off from the fields under it
- Dark mode repaints a rendered component from the app's dark tokens
- The surviving component-layer rules paint what nothing else does (regression)

### Component Schema Enhancements

- `className` on a `.card` element adds/overrides theme-derived classes
- `style` attribute provides inline CSS that overrides theme tokens
- Arbitrary Tailwind values (`bg-[#custom]`) override theme tokens
- Specificity: inline style > arbitrary value > className > theme token > fallback
- Responsive overrides (`md:bg-red-500`) work on theme-aware components
- `variant: 'default'` applies primary background and foreground colors
- `variant: 'destructive'` applies destructive background and foreground colors
- `variant: 'outline'` applies border with transparent background
- `variant: 'secondary'` applies secondary background and foreground colors
- `variant: 'ghost'` renders transparent with hover background
- `variant: 'link'` renders as styled text link with underline on hover
- `size: 'sm'` renders a compact button; `size: 'lg'` renders a larger button
- `size: 'icon'` renders a square button sized for icon-only content
- `loading: true` shows a spinner inside the button and disables interaction
- `badgeVariant: 'default'` applies muted background with muted-foreground text
- `badgeVariant: 'secondary'` applies secondary background and foreground
- `badgeVariant: 'destructive'` applies destructive background and foreground — the `error-bg` / `error-fg` pair, not the solid
- `badgeVariant: 'outline'` applies border with transparent background
- `alertVariant: 'default'` applies standard card background and foreground
- `alertVariant: 'destructive'` applies destructive color tokens (bg, text, border)
- `alertVariant: 'warning'` applies warning color tokens
- `alertVariant: 'info'` applies info color tokens
- `alertVariant: 'success'` applies success color tokens

### Form And Disclosure Component Enhancements

- `inputType: 'text'` renders a standard text input (default)
- `inputType: 'email'` renders an email input with browser email validation
- `inputType: 'password'` renders a masked password input
- `inputType: 'number'` renders a numeric input with increment controls
- `inputType: 'tel'` renders a telephone input with appropriate keyboard hint
- `inputType: 'url'` renders a URL input with browser URL validation
- `inputType: 'search'` renders a search input with clear button
- `accordionType: 'single'` allows only one item to be expanded at a time
- `accordionType: 'multiple'` allows multiple items to be expanded simultaneously
- `defaultOpen` specifies which item IDs are expanded on initial render
- Clicking a collapsed item header expands its content panel
- In single mode, expanding one item collapses the previously expanded item
- `tabsOrientation: 'horizontal'` renders tab triggers in a horizontal row
- `tabsOrientation: 'vertical'` renders tab triggers in a vertical column
- `defaultTab` sets which tab panel is visible on initial render
- Clicking a tab trigger switches the visible tab panel
- Active tab trigger is visually distinguished, not merely marked aria-selected
- Keyboard: arrow keys navigate between tabs, Enter/Space selects
