# Theming

How a `@lotics/ui` screen is painted, and how you repaint it.

**THE THEME IS CSS VARIABLES** — the shadcn/ui variable contract, so any shadcn or tweakcn palette
drops in unchanged and every derived value in the kit moves with it. There is no theme function and
no provider; a theme is a stylesheet, and that is the whole API.

**What you customize is TOKENS and PROPS.** Tokens are this document; props are `variant`, `size`,
`tone`, `render` and slots, plus `className`/`style` on an entry's ROOT for placement in your own
layout. The DOM inside an entry, its class names and its stylesheets are private engineering with
no compatibility promise — a gap the props do not cover is closed by a variant in the kit, never by
CSS that reaches inside one.

**Your rules win.** Kit CSS ships inside a cascade layer, so an unlayered declaration of yours
beats a layered kit rule at any specificity with no `!important` — which is what makes a token
override, and a `className` of your own on a root, land. It cuts both ways: overriding
`:root { --primary }` alone also beats the kit's `.dark { --primary }` and freezes that token at
its light value in dark mode (§6), and your unlayered *reset* beats us too, so a global reset
belongs in a layer.

```css
@layer base { @import "normalize.css"; }   /* a reset is layered */
:root { --primary: #0f766e; }              /* a deliberate override is not */
```

---

## 0. Which entries this document is about

**Every entry whose props extend `StyleProps`** — which its own declaration states, so
`dist/<module>.d.ts` naming an import of `./style_props` IS the answer for the entry you are
holding. Nothing else in this document is scoped narrower than that.

**An export that does NOT is one of three things.** One that DELEGATES its whole styling surface
to the entry it wraps takes §4's props and hands them straight down. One that renders no element of
its own has nothing for a class to sit on. And `Alert.alert(…)` is a call rather than an element,
so there is no `className` to accept and no `ref` to forward.

**One stylesheet.** `@lotics/ui/styles.css` is the entry point; everything else in §1's table is
optional.


## 1. Files, layers and delivery

### The files

| File | Layer | Holds |
|---|---|---|
| `src/tokens.css` | `lotics.tokens` | The public shadcn contract on `:root, .light` and `.dark`, then every derived `--lotics-*` token. **The only stylesheet permitted a literal colour, type size or z-index.** |
| `src/styles.css` | — (paints nothing) | The layer statements, then `@import`s of `tokens.css` and `reset.css`. The one entry point a consumer imports. Carries no `@font-face`. |
| `src/reset.css` | `lotics.reset` | The subtractive rules, scoped to elements carrying a `lotics-` class. Nothing else. |
| `src/<name>.css` | `lotics.components` | One stylesheet per component, beside `<name>.tsx`, imported by it. |
| `src/fonts.css` | none | `@font-face` only — one family, three weights, from package assets your own build emits. Optional. |
| `src/system-dark.css` | `lotics.tokens` | Opt-in: the `.dark` values under `prefers-color-scheme` (§6). Generated from the same source as the `.dark` block. |
| `src/tailwind.css` | none | The `@theme inline` colour and palette preset (§5). Optional. |
| `src/tailwind-type.css` | none | The `@theme inline` type and radius preset (§5). Optional, and it moves your existing `text-*` and `rounded-*` utilities onto the kit's scale. |
| `src/markdown.css` | `lotics.components` | Rendered prose — the one sheet that styles bare elements, because a markdown renderer emits an `h2` with no class to hang a rule on. Every selector is scoped under `.lotics-markdown`. |
| `src/markdown_editor.css` | none | The one component sheet outside every layer. |

Every one ships verbatim as `dist/<same name>`, and the ones a consumer imports have a subpath of
their own; a component's own sheet is reached by importing the component. **`none` means outside
every layer**, and the column is derived from the stylesheets by `css_contract.test.ts` in both
directions rather than kept by hand.

### Delivery

**The package ships built ESM, one module per entry**, with the module graph preserved — so a
screen using `Button` and `Text` ships one `Text`. Each entry imports its own CSS, so a component's
styles reach the browser only if that component is in the bundle. One package fact keeps that true:
**`"sideEffects": ["**/*.css"]`**.

**Every module that REACHES React carries `"use client"`** — transitively as well as directly, so a
React Server Component importing `Button` or `CalendarView` gets a client reference on the entry it
imported. The modules that reach no React (`format_money`, `spacing`, `color_tokens`, the other
pure tables) deliberately do not, and stay callable from a server component.

### The kit never paints the page

`--background` and `--foreground` are declared and applied to nothing, because the kit may not
style an element it does not own. Painting the ground is yours, and it is one rule:

```css
body { background: var(--background); color: var(--foreground); }
```

Without it, `class="dark"` gives you dark cards and dark buttons floating on the browser's white
canvas.

### When the tokens are missing

A component stylesheet assumes the tokens exist and declares no fallbacks. Forget `styles.css`
and every `var()` is invalid, leaving an unstyled but working control and no error, so the kit
makes it loud: one dev-only check per page reads `--lotics-space-16` off the document element and
`console.error`s the import line to add when it is empty.

---

## 2. Tokens

**The values below are the CONSTITUTION's** — [constitution.md](./constitution.md) says what each
scale is, why it has the rungs it has, and which suite holds it. This section is the machinery:
where a token is declared, what computes it, and which scope it sits on, which is what decides
whether your override reaches it.

### 2.1 The public contract — shadcn variables

Defined on `:root, .light`, redefined on `.dark`. **Nothing else in the kit is a colour source.**

`.dark` must come after the light block: both selectors have the same specificity and the tie is
broken by order. Declaring light on `.light` as well as `:root` is what makes the theme composable
— `<div class="light">` returns a subtree to light inside a dark shell.

The three rows with an em-dash in the Dark column are declared on `:root` **alone**, because the
theme does not move them and a declaration on `.light` would shadow yours inside every
`<div class="light">` (§2.3). Your own may sit wherever you like.

| Variable | Light | Dark | What it is | What moves when you change it |
|---|---|---|---|---|
| `--background` | `#ffffff` | `#09090b` | The page ground. | Every screen's canvas, and the ground the placeholder and disabled inks mix toward. |
| `--foreground` | `#18181b` | `#fafafa` | Body ink. | Every run of text with no other role, every neutral wash (they are alpha of this), the hover border. |
| `--card` | `#ffffff` | `#18181b` | A raised surface — card, drawer, modal. | `Card`, `Drawer`, `Dialog`, a field's own ground, an elevated `IconButton`. |
| `--card-foreground` | `#18181b` | `#fafafa` | Ink on a raised surface. | Text inside a `Card`. |
| `--popover` | `#ffffff` | `#18181b` | A floating surface — menu, popover, select list. | `Popover`, `ActionMenu`, `OptionList`, `Select` and `Combobox` popups. |
| `--popover-foreground` | `#18181b` | `#fafafa` | Ink on a floating surface. | Text inside those popups. |
| `--primary` | `#18181b` | `#fafafa` | The ink that ACTS. | The one filled button, a ticked checkbox, a switch's track, an active tab's rail. |
| `--primary-foreground` | `#ffffff` | `#18181b` | Ink on `--primary`. | The filled button's label, and the on-solid hover/press mix (§2.3). |
| `--secondary` | `#f4f4f5` | `#27272a` | The secondary button's ground. | `Button variant="secondary"`, `IconButton`'s secondary, `InlineButton`. |
| `--secondary-foreground` | `#18181b` | `#fafafa` | Ink on it. | That button's label. |
| `--muted` | `#f4f4f5` | `#27272a` | A quiet ground. | A table's header band, a disabled field, a skeleton. |
| `--muted-foreground` | `#66666f` | `#8b8b94` | ALL supporting text. Between two zincs, because the ramp has no rung that both recedes and clears AA — `constitution.md` §3. | Every caption, unit, timestamp and helper line, through `--lotics-ink-muted`; and the placeholder ink, which mixes off it. |
| `--accent` | `#f4f4f5` | `#27272a` | shadcn's neutral interaction ground. **Not the brand hue** — see §2.3. | The CHOSEN row of an `OptionList`. |
| `--accent-foreground` | `#18181b` | `#fafafa` | Ink on it. | That row's label. |
| `--destructive` | `#dc2626` | `#ef4444` | A destructive control's own colour. | The danger washes (§2.3) — three strengths of it, at rest, hover and press. |
| `--border` | `#e4e4e7` | `#27272a` | Every hairline. | Every divider, table rule, card edge and field edge. |
| `--input` | `#e4e4e7` | `#3f3f46` | A control's resting edge. | A field's own border, so a control can read differently from a divider. |
| `--ring` | `#18181b` | `#d4d4d8` | The focus ring. | Every `:focus-visible` outline in the kit. |
| `--radius` | `0.625rem` | — | The control rung; every other radius is a MULTIPLE of it (§2.3). | All four altitude rungs, and `tailwind-type.css`'s `rounded-*`. |
| `--font-sans` | `Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif` | — | Everything, unless a run asks for mono. | Every part that paints its own words. |
| `--font-mono` | `ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace` | — | Code, keys, identifiers. | `RawPayload`, markdown code, a workflow's source. |

**The table is what the kit READS, and nothing else.** Fourteen names shadcn's own template
carries are therefore absent (`--chart-1..5`, the eight `--sidebar-*`, `--destructive-foreground`):
the kit ships no navigation rail, its charts take their colours from the palette and from what you
pass them, and it paints no SOLID destructive surface. Set them in your own theme if your own
components want them.

`:root` also sets `color-scheme: light` and `.dark` sets `color-scheme: dark`, so scrollbars, form
controls and the caret follow the theme without a rule each.

**`--font-sans` is the only family token.** `fonts.css` registers the default face under **one**
family name at three weights, so an ordinary numeric `font-weight` selects a real face.
`styles.css` carries no `@font-face`: import `@lotics/ui/fonts.css`, or self-host a face under the
name `--font-sans` gives.

**The four values a theme is most likely to want to move are `--primary`, `--foreground`,
`--border` and `--radius`**; everything else derives from the table above.

### 2.2 The palette

The 22 families the data-viz and valence vocabulary is built from — `red`, `orange`, `amber`,
`yellow`, `lime`, `green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,
`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone` — each at 11 shades, emitted
as `--lotics-zinc-600: #52525b`. Plus one achromatic rung, `--lotics-white`, which is what a glyph
over IMAGERY takes: every ink role moves with the theme and a photograph does not.

**The palette is not themeable, and that is the design rather than an omission.** A family name is
how this system says what something IS — a status, a valence, a series — so letting a theme repaint
one would not restyle the product, it would make it lie.

**What a surface reads is the family's TONE — a rung reaches a sheet or a `style` only through one
of these four names.** (The JS palette — `colors`, `ramp()` — is the DATA path: a chart series, a
record option's colour. That is what it is for, and what it stays for.)
A rung is ONE colour in both themes, so a sheet that grounded a status pill in `green-50` under
`green-900` painted the same pill on white and on near-black. Four names per family, three of them
declared per theme:

| Token | Is | Light / dark |
|---|---|---|
| `--lotics-tone-<family>` | the wash a tonal surface takes | 50 / 950 |
| `--lotics-tone-<family>-strong` | the same tone with body — its edge, and the ground of a mark too small to carry a wash | 100 / 900 |
| `--lotics-tone-<family>-ink` | the ink on that wash | 900 / 200 |
| `--lotics-tone-<family>-solid` | a dot, a series, a meter's accent — ONE value in both themes, because six pixels are read against both grounds | 500, and 600 or 700 per family |

The five achromatic families take 100 / 200 / 800 / 700 for the first two instead:
`--lotics-zinc-950` IS `--background` in dark, so those rungs paint a pill nobody can see. The
solid's rung is per family because WCAG 1.4.11 asks a graphic for 3:1 on the canvas and nine
families' 500 is under it on white: those take the lightest rung clearing BOTH canvases, the 600
and the 700 for yellow. `solid()` on `@lotics/ui/colors` returns exactly that value, so JS and CSS
paint one colour. Move a tone to repaint every status, callout and chip of that family; a tint is
`color-mix(in srgb, var(--lotics-tone-emerald-solid) 20%, transparent)`, and a ramp of N shades of
one family is the six stops `700 600 500 400 300 200`.

A theme that repaints `--destructive` repaints its own destructive controls; the kit's danger
surfaces stay red, because red is the meaning and not the brand. **Charts read the palette**, never
a themeable categorical set: the kit's charts take a `ColorName` or an explicit colour from the
call site. Past five categorical series they group the tail rather than inventing a sixth hue.

### 2.3 Derived `--lotics-*` tokens

Most are computed in CSS from §2.1, so a theme reaches them. The two that are not have knobs of
their own: `--lotics-accent` and `--lotics-shadow-color`. `color-mix` is always `in srgb`, never
`in oklab` — these formulas reproduce sRGB alpha composites, and an oklab mix of the same
percentage is a different colour.

**Where a token is declared decides whether a theme can reach it, and the rule is an `if and only
if`: a token the theme moves — one `.dark` gives its own value, or one that reads such a token
however deep — is declared on `:root, .light, .dark`; everything else on `:root` alone.** A custom
property substitutes its `var()`s at computed-value time on the element carrying the declaration,
not lazily where it is used, so both halves bite. On `:root` alone, a wash mixed off `--foreground`
freezes at the root's foreground for the whole document and paints near-black on near-black inside
`<div class="dark">`. On all three, our declaration sits ON the element your `:root` override only
reaches by inheritance, and beats it there — so a token you set once (`--lotics-shadow-color`, the
spacing scale, the type ramp) is on `:root` alone, and the ones that are not (the wash ladder,
`--lotics-border-hover`, the ink roles, `--lotics-shadow-hairline`) are overridden the way
`--primary` is: in both blocks, or by moving the input they read.

**Brand hue** — a fixed value, and one of the two knobs.

```css
--lotics-accent: #2563eb;                                             /* dark theme: #60a5fa */
--lotics-accent-wash: color-mix(in srgb, var(--lotics-accent) 7%, transparent);   /* dark: 18% */
```

It is deliberately not shadcn's `--accent`, which in that vocabulary is a neutral interaction
ground — mapping ours onto it would paint every menu hover blue. Nor is it `--primary`:
`--lotics-accent` marks WHICH OF THESE (an active filter, a region lit under a dragged file),
`--primary` marks THIS IS ON (a control's state, the section you are in).

**The neutral interaction ladder**, for a control or row whose ground is a **surface** token
(`--background`, `--card`, `--popover`, `--muted`): `--lotics-wash-hover`, `-selected`, `-press`,
and the `-control-hover` / `-control-press` pair. Each is
`color-mix(in srgb, var(--foreground) <rung>, transparent)` — the five rungs are `constitution.md`
§6 — so one theme knob moves all five and each composites over whatever ground the row sits on.
`--lotics-wash-control-hover` reads `var(--lotics-wash-press)`, so the two ladders share that rung
by construction.

**Which two rungs a thing takes, in full**, because a surface ground is a ladder and not a pair:

| Surface | hover | press |
|---|---|---|
| A row you can select (`PressableRow`, a register row) | `--lotics-wash-hover` | `--lotics-wash-press` — with `--lotics-wash-selected` between them, for the state |
| A control RIDING such a row (`IconButton`) | `--lotics-wash-control-hover` | `--lotics-wash-control-press` |
| A control standing on the page (`Button`, `ListItem`) | `--lotics-wash-selected` | `--lotics-wash-press` |

The third row is the one worth reading twice: a standalone control spends the **selection** rung on
its hover, having no selected state to collide with. So a theme moving `--lotics-wash-selected`
moves both a row's selection and a button's hover; move `--foreground`, or all three rungs
together, if you mean only one of them.

**The on-solid ladder**, for a control whose ground is a **solid role** token (`--primary`,
`--destructive`, a themed accent). The neutral ladder cannot serve these: on the default theme
`--primary` *is* `--foreground`, so a foreground-mixed hover paints near-black on near-black. The
mix runs toward the ground's own ink instead, and these two are the STRENGTHS of that mix rather
than colours, because the component is the only thing that knows which ink:

```css
--lotics-on-solid-hover: 10%;
--lotics-on-solid-press: 18%;
```

```css
.lotics-button[data-variant="primary"]:hover {
  background: color-mix(in srgb, var(--primary-foreground) var(--lotics-on-solid-hover), var(--primary));
}
```

A primary lifts toward its own ink and a brand ground whose ink is already white darkens instead,
because lifting it would wash the colour out. **The pairing rule, in one line:** a control whose
ground is a surface token takes the neutral wash ladder; a control whose ground is a solid role
token takes the on-solid ladder.

**Disabled** is one treatment everywhere — opacity down, cursor default, never a second colour:
`--lotics-disabled-opacity: 0.5`.

**Edges, hairlines and the ring**

```css
--lotics-border-hover: color-mix(in srgb, var(--foreground) 55%, var(--border));
--lotics-ring-width:   2px;
```

Colour tokens only, no `border` shorthands: a shorthand also resets `border-image` and would fight
the rule that motion runs on `border-color`. A bordered control has exactly two **interactive**
signals — **focus is the ring, hover is a darker border** — plus, where it can be selected, one
**state edge**. A third interactive signal on one control breaks the pattern for all of them.

The ring is an `outline`, never a `box-shadow`.

```css
.lotics-button:focus-visible { outline: var(--lotics-ring-width) solid var(--ring); outline-offset: 0; }
```

**Selection's own edge**, because an element has one `outline` and a chip that is both selected and
focused must render both:

```css
.lotics-chip[data-selected] {
  border-color: var(--primary);
  box-shadow: inset 0 0 0 1px var(--primary);   /* layout-neutral; stacks under the focus outline */
}
```

**A composite control** — one bordered shell containing several focusable segments, like a date
field — paints the ring on the **shell** via `:focus-within` and marks the focused inner segment
with `--lotics-wash-selected`. Never a second outline. A **separate control riding inside** that
shell — a field's clear ✕ — goes the other way: the ✕ keeps its own ring and the shell stands down,
so there is still exactly one.

```css
.lotics-date-field:focus-within { outline: var(--lotics-ring-width) solid var(--ring); }
.lotics-date-field__segment[data-focused] { background: var(--lotics-wash-selected); }
.lotics-text-input:has(.lotics-text-input__clear:focus-within) { outline-style: none; }
```

**Ink roles.** Text addresses ink only through these; there is no raw neutral to reach for.

```css
--lotics-ink-default:    var(--foreground);
--lotics-ink-muted:      var(--muted-foreground);   /* ALL supporting text */
--lotics-ink-inactive:   var(--lotics-zinc-400);    /* fixed; dark: --lotics-zinc-500 */
--lotics-ink-placeholder: color-mix(in srgb, var(--muted-foreground) 95%, var(--background));
--lotics-ink-on-inverse: var(--primary-foreground);
--lotics-ink-danger:     var(--lotics-red-900);     /* fixed; dark: --lotics-red-400 */
--lotics-ink-warning:    var(--lotics-amber-700);   /* fixed; dark: --lotics-amber-400 */
--lotics-ink-success:    var(--lotics-emerald-700); /* fixed; dark: --lotics-emerald-400 */
```

`muted` is a BLEND of two palette rungs, because the ramp has no step satisfying both bounds
`constitution.md` §3 puts on it; move `--muted-foreground` and both move with it. **`inactive` and `placeholder` are not interchangeable**, which is the whole reason
there are two: `inactive` reads as inert on purpose and sits below the 4.5:1 floor, which WCAG
1.4.3 exempts because a disabled control's label is not content, while a prompt in an empty field
is live text and gets no exemption. Reach for `placeholder` for a `::placeholder`, a drawn hint
over an empty field and an unset value in an inline editor; for `inactive` only where the control
is off.

**Which ink to reach for:** an element sitting on a **named surface** takes that surface's own
foreground pair — `--card-foreground`, `--popover-foreground`, `--primary-foreground`. The
`--lotics-ink-*` roles are the page-ground spelling plus the four valence roles, which have no
shadcn pair and resolve to palette shades because danger is red for the same reason §2.2 gives.

**Spacing.**

```css
--lotics-space-2 … --lotics-space-48   /* the ten rungs; the name IS the pixel — constitution.md §1 */
--lotics-page-pad-narrow: var(--lotics-space-16);
--lotics-page-pad-wide:   var(--lotics-space-32);
--lotics-page-pad:  var(--lotics-page-pad-narrow);   /* ≥768px: var(--lotics-page-pad-wide) */
--lotics-dialog-gutter: var(--lotics-space-16);      /* ≥768px: var(--lotics-space-24) */
--lotics-row-wash-bleed: var(--lotics-space-8);
```

The page gutter is one PAIR for a list and the record it opens: a gutter that differs between them
slides the content sideways at the moment the reader changes screens. `--lotics-page-pad` answers
from the window, because a `:root` declaration has no other axis, and a surface that establishes a
container answers from its own box — so **move the pair, not `--lotics-page-pad`**.

**Control metrics.** Every band control aligns to one height, so a toolbar row, a record's value
column and an option list read as one band.

```css
--lotics-control-height:         40px;
--lotics-control-content-height: 28px;   /* the tallest fixed-height node a control may seat */
--lotics-control-pad-y:          5px;    /* (40 − 28) / 2 − 1: the 1px border is part of the air */
--lotics-control-pad-x:          8px;
--lotics-control-text-inset:     9px;    /* 1px border + 8px padding — what a hanging line aligns to */
--lotics-control-min-width:      160px;  /* can this still be OPERATED */
--lotics-value-min-width:        200px;  /* can this still be READ — deliberately larger */
```

**The register band** (`constitution.md` §2). Move these and all four registers move.

```css
--lotics-register-row:      var(--lotics-control-height);
--lotics-register-head:     var(--lotics-register-row);
--lotics-register-inset:    var(--lotics-space-12);   /* between columns of WORDS */
--lotics-register-cell-gap: var(--lotics-space-2);    /* between columns of GLYPHS */
--lotics-register-line:     var(--lotics-leading-tight-sm); /* a cell's LINE, × `lines` */
--lotics-register-ground:   var(--background);        /* what a STICKY head paints */
```

The ground is re-pointed by `Card`, `Dialog`, `Drawer` and `Popover` in their own sheets, so a
register inside one needs no prop.

**Identity marks.** One scale for every avatar, and two of its rungs ARE control metrics: `md` is
the largest mark a 40px band can seat, `lg` is the mark that IS a control.

```css
--lotics-avatar-sm: 24px;
--lotics-avatar-md: var(--lotics-control-content-height);
--lotics-avatar-lg: var(--lotics-control-height);
--lotics-avatar-xl: 72px;
--lotics-lead-gap:  var(--lotics-space-10);  /* mark to label — the ONE gap, everywhere */
```

The mark's COLOUR is dealt from the subject, never set at a call site (`constitution.md` §8's one
exception): eight families, a pair each — `--lotics-identity-<family>` the ground and
`--lotics-identity-<family>-ink` the letters, both themed, the family's 200 under its 800 in light
and its 900 under its 300 in dark. Move a pair to rebrand the marks. The agent alone takes a mix of
`--lotics-accent` toward the canvas, and a subject with no name takes `--muted`.

**Radius.** Four rungs, picked by what the thing IS, every one a multiple of `--radius`:
`--lotics-radius-control`, `-card`, `-overlay`, `-full`. What a sheet may WRITE is
`constitution.md` §5's.

**Shadows.** Anchored on `--lotics-shadow-color` rather than on `--foreground`, because a shadow
stays dark in a dark theme; it is the second of the two knobs, and one `:root` rule carries it into
every subtree. Only the hairline follows the theme, being an edge and not a shadow.

```css
--lotics-shadow-color:    var(--lotics-zinc-950);
--lotics-shadow-hairline: 0 1px 2px 0 var(--border);
--lotics-shadow-raised:   0 0 6px 1px color-mix(in srgb, var(--lotics-shadow-color) 19%, transparent);
--lotics-shadow-panel:    -8px 0 24px color-mix(in srgb, var(--lotics-shadow-color) 8%, transparent);
--lotics-scrim:           color-mix(in srgb, var(--lotics-shadow-color) 15%, transparent);
--lotics-scrim-strong:    color-mix(in srgb, var(--lotics-shadow-color) 55%, transparent);
```

**Motion.** Three durations named for what moves, two easings, and one rule.

```css
--lotics-duration-fast: 120ms;   /* a control's own edges and ground */
--lotics-duration-base: 180ms;   /* something appearing: an overlay, a disclosure */
--lotics-duration-slow: 240ms;   /* something travelling: a drawer, a sheet */
--lotics-ease-standard: cubic-bezier(0.2, 0, 0, 1);
--lotics-ease-exit:     cubic-bezier(0.4, 0, 1, 1);

@media (prefers-reduced-motion: reduce) {
  :root {
    --lotics-duration-fast: 1ms !important;
    --lotics-duration-base: 1ms !important;
    --lotics-duration-slow: 1ms !important;
  }
}
```

**1ms, never 0.** An overlay that waits for `transitionend` before unmounting never unmounts at
zero — no transition runs, so no event fires, and the reduced-motion user is the only one who finds
a dialog that will not close. Collapsing at the token is why no component needs a
`prefers-reduced-motion` rule of its own — **except one that LOOPS**, where 1ms is faster rather
than still, so a pulse or a spinner stops itself.

**This block is the kit's only `!important`, and the only kit rule that outranks you from inside
the layer**. **Retime these from wherever you like**: a plain
`:root { --lotics-duration-base: 240ms }` retimes the kit and still collapses.

**Z-index.** Two ladders that are never compared with each other.

```css
/* Overlays — a published ladder: anything outside the kit that must clear a Lotics
   overlay reads a rung from here rather than picking a literal. */
--lotics-z-overlay:       9999;    /* every overlay, and a popover */
--lotics-z-overlay-above: 10000;   /* a tooltip, an alert — ABOUT the surface under them */
--lotics-z-notification:  10001;   /* a toast */
--lotics-z-skip-link:     10002;   /* the first thing a keyboard reaches */

/* In-page — LOCAL to the stacking context the component establishes. */
--lotics-z-sticky-header: 1;
--lotics-z-sticky-column: 2;
--lotics-z-drag:          3;
```

The in-page rungs mean nothing outside their own stacking context — never compare one with an
overlay rung. **The overlay rung goes on the positioner, not the popup**: for every Base UI overlay
except Dialog the positioner is the positioned element, so a `z-index` on the popup raises nothing.

**Type.** Two curves, stated once. `line-height` sets the box height of even a single line, so
these decide the height of every label, value and control in the kit.

| Rung | size | leading | tight leading | tracking | size ≥768 | leading ≥768 | tight ≥768 |
|---|---|---|---|---|---|---|---|
| `xs` | 12px | 18px | 18px | `0em` | 12px | 18px | 18px |
| `sm` | 14px | 24px | 20px | `-0.006em` | 14px | 24px | 20px |
| `md` | 16px | 26px | 22px | `-0.011em` | 16px | 26px | 22px |
| `lg` | 18px | 28px | 24px | `-0.014em` | 20px | 30px | 26px |
| `xl` | 22px | 28px | 28px | `-0.018em` | 24px | 32px | 30px |
| `xxl` | 28px | 34px | 34px | `-0.021em` | 32px | 44px | 38px |
| `xxxl` | 32px | 38px | 38px | `-0.022em` | 48px | 52px | 52px |

Emitted as `--lotics-text-<rung>`, `--lotics-leading-<rung>`, `--lotics-leading-tight-<rung>`,
`--lotics-tracking-<rung>`, with the four `≥768` columns redefined inside one
`@media (min-width: 768px)` block.

- **Leading**: the body rungs carry PROSE leading (14/24), what rendered markdown uses, so a typed
  note and the same note rendered sit at one rhythm. `tight` is `size + 6` capped at that, for a
  STACKED PAIR — prose leading puts ~10px of empty box between a subject and its supporting line,
  so the pair reads as two things.
- **Tracking** is the default face's own dynamic-metrics curve per rung, stated once in `em` and
  never per breakpoint. Never nudge it: a hand-set `letter-spacing` in a component sheet is a
  second copy of the curve. The rung it does not answer is CAPS, drawn for lowercase —
  `--lotics-tracking-caps` (`0.05em`) is what a micro-cap label adds on top of its rung.

Hand-rolled DOM reaches the ramp by naming the tokens together — `font-family`, `font-size`,
`line-height`, `letter-spacing` and `font-feature-settings: var(--lotics-font-features)`. A field
you TYPE INTO takes the input rung — 16 below 768, the size under which Safari zooms a focused
field. A value you READ, a frameless field at REST included, takes the value rung, so an editable
fact and the read-only one beside it are one line of type. One weight token serves both
(`--lotics-input-weight`): no floor moves it.

```css
--lotics-input-text:     var(--lotics-text-md);      /* ≥768px: var(--lotics-text-sm) */
--lotics-input-leading:  var(--lotics-leading-md);   /* ≥768px: var(--lotics-leading-sm) */
--lotics-input-tracking: var(--lotics-tracking-md);  /* ≥768px: var(--lotics-tracking-sm) */
--lotics-value-text:     var(--lotics-text-sm);
--lotics-value-leading:  var(--lotics-leading-sm);
--lotics-value-tracking: var(--lotics-tracking-sm);
```

Weights are numeric and there are three: `--lotics-weight-regular: 400`, `-medium: 500`,
`-semibold: 600`. `--lotics-font-features` carries the default face's stylistic alternates, a token
rather than a line in `Text` because `font-feature-settings` inherits from nothing the kit owns —
**every part that paints its own words names it**. `--lotics-font-display: var(--font-sans)` is the
face a display run wears, separate because swapping a headline face is one value while the BODY
face is a tracking curve, a set of alternates and a diacritic check as well.

### 2.4 Measuring the same values in JS

A JS colour here is a `var()` passed through. Beside that, a few modules carry the tokens as
**numbers** — `type_ramp.ts`, `spacing.ts`, `control_surface.ts`, `color_tokens.ts` — because
anything that MEASURES text rather than declaring it needs the number, and CSS cannot be read back
cheaply. Tests parse `tokens.css` and fail when a value disagrees: the CSS declares, the module
measures, neither moves alone.

---

## 3. Classes and attributes

**The class names are PRIVATE and carry no compatibility promise** — they are a debugging and test
hook, not a selector to write rules against, and a rename is not a breaking change. The convention
is recorded so a rendered tree can be read and a theme reasoned about.

**One attribute is PUBLIC, and it is for MEASUREMENT rather than style**: the kit writes
`data-mark="identity"` on every identity mark's root — `Avatar`, whatever its subject, and so every
entry that draws one — so a review probe can count the marks on a screen without keying on a class;
no sheet of ours selects on it, nothing else below a root is public, and a rule of your own anchored
on it alone reaches every mark in the tree.

**So is every `--lotics-*` name §2 does not list.** The ones it lists are the tokens and setting
one is this contract; the rest are channels between a sheet and its own parts, renamed without
notice. Your own custom property takes your own prefix — a `--lotics-` name you invent is one the
kit can spend on a token in any release.

**Root class** — `lotics-<anatomy>`, kebab-cased: the ANATOMY, not the module and not the export
name. **Part class** — `lotics-<anatomy>__<part>`. A component that is a MEMBER of a larger anatomy
renders that anatomy's part class whatever module it lives in: `ChecklistItem` renders
`lotics-checklist__item`.

**Three attribute axes**, each a single value — a destructive secondary button is
`data-variant="secondary" data-tone="danger"`, never `data-variant="danger-secondary"`:

| Attribute | Axis | Values |
|---|---|---|
| `data-variant` | emphasis and shape | `primary`, `secondary`, `ghost`, `tonal`, … |
| `data-tone` | valence | `danger`, `warning`, `success`, `info` |
| `data-size` | scale | `sm`, `md`, `lg` |

**State** is Base UI's attribute wherever a Base UI part emits one (`data-open`, `data-checked`,
`data-disabled`, `data-highlighted`, …), and `data-<state>` where the kit owns it. **One rule turns
state into attributes, and it is Base UI's**: `true` → the attribute with an empty value; any other
truthy value → `String(value)`; `false`, `null`, `undefined`, `0` and `""` → no attribute at all;
and the key is **lowercased**, not kebab-cased. **A boolean state is PRESENCE, never a value** —
`[data-disabled="true"]` will never match. The same rule reads your own `data-*` differently: §4.

---

## 4. The component prop contract

Every component §0 scopes this document to accepts, on top of its own props:

```ts
export type StyleValue = Omit<React.CSSProperties, "lineHeight"> & { lineHeight?: string };

export interface StyleProps {
  /** Appended LAST to the root's class list. */
  className?: string;
  /** Merged LAST into the root's inline style. */
  style?: StyleValue;
  /** A state of the consumer's own, landing on the root beside the kit's. */
  [dataAttr: `data-${string}`]: string | number | boolean | undefined;
}
```

**That is the whole styling surface, and it addresses the ROOT** — the box your layout contains.
There is no per-part channel and nothing that strips the kit's own classes: what an entry renders
inside itself is private (§3), so a gap the props do not cover is closed by a variant in the kit.

**A `data-*` you pass lands on the root**, by the DOM's own rule: `undefined` writes no attribute
and every other value is written verbatim, so `data-x={on ? "" : undefined}` is the empty attribute
a `[data-x]` selector wants. Use it for a state of YOUR own — which section is current, which row
is being renamed — and select on it from your own layout rule. A key the component publishes itself
wins the collision: its own state is its contract.

`StyleValue` is CSS with one property narrowed. `lineHeight` takes a **string** because a number is
px in most native style languages and a unitless RATIO in the DOM — `lineHeight: 24` meant as a
24px line box renders as a 336px one on 14px text, compiling either way.

`className` is a **string**. Base UI's raw parts also accept a function of the part's state; kit
entries do not, because a state-driven class is an attribute selector's job.

Plus `ref` (React 19 — a normal prop, no `forwardRef`), typed to the **default** element; `render`,
Base UI's substitution prop; and `testID`, which renders `data-testid`.

---

## 5. Tailwind

Tailwind is **supported, not required**. The kit uses no utility class internally, so you never
need `@source` or `tailwind-merge` to beat it. The preset ships as **two** files, because they
carry different risks.

**`tailwind.css`** — colour and palette. New namespace keys, nothing of yours redefined: one
`--color-*` per §2.1 variable, then `--color-lotics-<family>-<shade>` for all 22 families at 11
shades, GENERATED from the tokens (`npm run generate:tailwind-palette`). **No spacing keys**, and
none are needed: the kit's rungs are already `p-0.5` … `p-12` at Tailwind's own `--spacing` base,
and a `--spacing-2` of ours would silently redefine your `p-2` from 8px to 2px. `inline` is what
makes the generated utilities reference the variable rather than copy it — without it `.dark` moves
the variable and no utility notices.

**`tailwind-type.css`** — type and radius, separate because these keys are Tailwind's **own**.
Importing it moves every `text-*` and `rounded-*` utility already in your codebase onto the kit's
scale: your `text-sm` stops being 14/20 and becomes 14/24. That is usually what you want when
adopting the kit's type system and never what you want by accident.

Neither preset maps `--font-sans` or `--font-mono`: Tailwind's namespace key and the kit's token
are the same identifier, so the mapping would be a self-reference. Set Tailwind's font namespace
yourself, to whatever you set `--font-sans` to.

**The utilities are for YOUR OWN elements.** A kit entry is configured through its props; a utility
aimed at one reaches inside it, which is the thing the contract does not keep.

**Import order, and it is not optional:**

```css
@layer theme, base, lotics, components, utilities;   /* your entry CSS, first line */
@import "tailwindcss";
@import "@lotics/ui/styles.css";
@import "@lotics/ui/tailwind.css";
@import "@lotics/ui/tailwind-type.css";              /* opt in deliberately */
```

That first line is the one race the kit cannot win for you: `@import "tailwindcss"` registers a
layer order of its own, and our later statement then introduces `lotics` as a NEW name after
`utilities` — so kit components beat your utilities. DevTools' Styles pane names the layer a
matched rule came from; check it rather than trusting it.

---

## 6. Recipes

### Theme it

**Take the CURRENT shadcn format.** Two are still served: today's writes a whole colour
(`oklch(0.205 0 0)`, `#18181b`), and the older one writes a BARE HSL TRIPLET (`--primary: 0 0% 9%`)
that its components wrap at every use. This kit reads a colour, so a triplet theme makes every rule
naming it invalid at computed-value time — the page comes up unpainted, with nothing in the console
and a struck-through declaration in DevTools. The kit `console.error`s it on the first render it
can; the fix is to take the current export, or to wrap each triplet in an HSL function yourself.

**Paste the whole export, both blocks.** A token you set only on `:root` keeps its light value in
dark mode, because your unlayered rule beats our layered `.dark`. Hand-picking one line is the one
way to get a half-dark screen with no error.

```css
@layer theme, base, lotics, components, utilities;
@import "@lotics/ui/styles.css";
@import "@lotics/ui/fonts.css";        /* or self-host a face under the name below */

:root, .light {
  --primary: #0f766e;
  --primary-foreground: #ffffff;
  --ring: #0f766e;
  --radius: 0.5rem;
  --font-sans: "Be Vietnam Pro", system-ui, sans-serif;
}

.dark {
  --primary: #5eead4;
  --primary-foreground: #042f2e;
  --ring: #5eead4;
}

body { background: var(--background); color: var(--foreground); }
```

Everything derived follows: both interaction ladders, the focus ring, both radius rungs, the whole
type ramp's family. The two fixed anchors are set in different places: `--lotics-accent` is themed,
so it goes in **both** blocks; `--lotics-shadow-color` is not, so one `:root` rule carries it
everywhere. So does a retiming.

Two things to check when you replace `--font-sans`. The face must actually resolve — a family
nobody `@font-face`d falls silently through to system sans. And check Vietnamese: a face without
the diacritics turns `Thẩm định` into tofu. The kit's tracking curve is drawn for its default face,
so another face inherits tracking chosen for a different drawing.

### Dark mode

Put `class="dark"` on `<html>`, or on any subtree that should be dark, and the whole contract
re-resolves, `color-scheme` included. `class="light"` returns a subtree to light inside it.

To follow the OS instead, import the shipped sheet rather than copying the palette:

```css
@import "@lotics/ui/system-dark.css";   /* prefers-color-scheme: dark, on :root:not(.light) */
```

**It declares the KIT's dark values, which is what decides whose theme wins.** Measured on a
machine set to dark, with no class on the root:

| Your dark values are | What resolves |
|---|---|
| unlayered, under `prefers-color-scheme` | **yours** — an unlayered rule beats every layered one |
| inside `@layer theme` | **ours** — that layer sits before `lotics` in the kit's order statement |
| on `.dark` alone | **neither**: the class never matches, so this sheet supplies our dark GROUND while your unlayered `:root` supplies your LIGHT ink — a half-dark screen |

So keep a deliberate override UNLAYERED, and if your theme is class-based — which a tweakcn export
is — pair it with the class-setting script below rather than with this sheet.

**No flash on first paint.** A class-based theme needs the class set *before* first paint, or a
prerendered page paints light and flips. That is a render-blocking script in `<head>`:

```html
<script>
  const t = localStorage.getItem("theme");
  document.documentElement.classList.add(
    t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light",
  );
</script>
```

### Substitute the element, or the whole component

`render` takes an element to merge into, or a function of the component's state. Props, event
handlers, `className` and `style` are merged rather than replaced, so the component's own behaviour
survives.

```tsx
<Text render={<a href="/records/1" />}>Open record</Text>
<PageContent render={(props, state) => <main {...props} data-wide={state.size === "wide"} />} />
```

**`render` substitutes the ELEMENT, not the semantics.** An attribute the substituted element
cannot carry becomes its ARIA spelling and the component suppresses the behaviour itself:
`disabled` is not valid on an `<a>`, so a disabled control rendered as an anchor emits
`aria-disabled` and stops activating. `ref` stays typed to the default element while resolving at
runtime to whatever you rendered.

Where you want the behaviour without the kit's appearance, build on the same Base UI parts. A kit
entry that wraps Base UI *is* a styled Base UI part exposing Base UI's anatomy, so dropping to Base
UI's own parts is not a rewrite: the same elements, the same state, the same anatomy, with your CSS
on them.

---

## 7. Server rendering

An entry renders no non-deterministic markup — no generated class names, no ids from a counter, no
values read from `window` during render — so a server render and the first client render produce
byte-identical `class` and `data-*`. The one value the server cannot know is the viewport:
`useScreenSize` (and `useContainerSize` with no boundary above it) answers **medium** on the server
and on the client's first render, then re-renders to the measured bucket once hydrated.

**The kit draws its own client boundary.** Every module that reaches React carries `"use client"`,
so a Server Component may import `Button` or `CalendarView` directly — no wrapper module of yours
and no `transpilePackages` entry. The one place that does need telling is a UNIT TEST RUNNER, which
resolves `node_modules` through Node and has no loader for the `.css` each module imports →
[testing.md](./testing.md).
