# The constitution

The numbers the kit is built on, on one page. Ten rules. Each is a value a test checks — the
suite that checks it is named beside it.

**The idiom is FLAT.** Lotics is washes on the canvas, rhythm and hairlines — never a page of
boxes. A card is a ROLE (a selectable item, a KPI tile), not a way of grouping things that belong
together; whitespace groups them. Rule 6 is that sentence as numbers.

---

## 1. Space — ten rungs, each named by the pixel it is

```
--lotics-space-2   4   6   8   10   12   16   24   32   48
```

**A sheet writes a rung or nothing.** Inside a `calc()` made of nothing but the scale, a rung is a
WHOLE TERM, at most one per expression, and `* -1` is the only multiplier: two rungs may never be
summed into a third (8 + 4 is 12, and 12 is a rung) and a rung may not be scaled (`* 1.5` is a
length on no scale). An expression ANCHORED to a measure the component owns is that component's
geometry rather than the scale, and may compose freely —
`calc(var(--lotics-control-height) / 2 - var(--lotics-space-4))`. A rung ALIASED by the
component's own property is followed. Off the scale there is nothing: a value that is not a
rung moves to the nearest one, and a tie goes to the tighter.

**A rung is a rung wherever it is written** — a sheet, a `style` object, a layout prop: one that
writes `padding: 20` teaches 20.

**No eleventh rung, none above 48.** A gap larger than 48 means two screens.

*Checked by* `css_contract.test.ts` — `space-arithmetic` in the kit, `ui-internal` and the
frontend, `spacing-literal` in the kit and `ui-internal`; `tokens_agreement.test.ts` holds the CSS
and the `SpaceToken` union to the same ten numbers in both directions.

## 2. Grid — what each rung is for

| Between | Rung |
|---|---|
| Two halves of one pair (a label and its value, a glyph and its word) | **4** |
| A hairline offset, an optical nudge | **2** |
| Items inside one group | **8** |
| Peers — one band and the next | **24** |
| Sections | **48** |

That is 1 : 3 : 6, and it is the whole rhythm of a screen.

**A container's gap exceeds the largest gap INSIDE what it holds — the kit's included.** A section
at 24 around rows spaced 24 draws the edge between two objects no wider than one inside one.

**6, 10 and 12 are CONTROL INTERIORS** — a chip's inset, the gap beside an identity mark, a row's
inset. **16, 24, 32 and 48 are the region
rungs**; 16 is the page gutter under 768px and 32 above it, and 16 / 24 is the same pair
for a DIALOG, whose edge is the popup's rather than the screen's — a card 32 in from its own border
reads as a card inside a card.

**A REGISTER'S BAND is a FLOOR, and it is one number.** `Table`, `DataGrid`, `StateMatrix` and
`Timetable` take their row and their head from `--lotics-register-row` / `-head` (40 — the control
height: a pressable cell's whole box is the target); taller content grows it. Between columns
words take `-inset` (12), glyphs of one object `-cell-gap` (2), a `StateMatrix`'s cells 8 and rows 12.

A ROW THAT CARRIES A SUBJECT stands on `--lotics-row-beat` (a floor of 72, content-measured above) — a
register's row, a run's stop, a list's item, a document row that IS its section's subject. A run's
RUNG is a position, not a subject, with its own rhythm.

**A ROW ALIGNS AND BREATHES BY ONE RULE** (`row.css`): it stands in `--lotics-row-inset` (10)
above and below; the title over up to two `xs` lines is pulled in at both ends by its half-leading,
`(size − tight leading) / 2`, and a block no taller than the lead centres on it, a taller one hangs
its cap on the lead's top edge; a rail's node centres on the lead, status, figure and acts on the row.
No surface pads its own row.
**Air is a floor, never what a fixed height leaves**: a menu row a wrapped name or a `description`
grows keeps its band's air.

*Checked by* `tokens_agreement.test.ts`, which reads the pairs, the band and the beat off the
sheet, `table_rhythm.test.tsx` over every row-shaped surface, and `css_contract.test.ts`
(`register-band`) in the kit and `ui-internal` — a register composes no band of its own; the
container rule by `detail_row.test.tsx` and `state_matrix.test.tsx`; the alignment by
`npm run probe:rows`.

## 3. Ink — three rungs and a prompt, and nothing else paints text

| Role | Is | Contrast on the canvas |
|---|---|---|
| `--lotics-ink-default` | `--foreground` | 17.7 : 1 |
| `--lotics-ink-muted` | `--muted-foreground` | **4.5 – 6.5 : 1** — 5.68 light, 5.89 dark |
| `--lotics-ink-inactive` | a disabled label, an unset value | below the floor, and exempt (WCAG 1.4.3) |
| `--lotics-ink-placeholder` | a prompt in an empty field | above the floor, below `muted` |

Plus four VALENCE roles — danger, warning, success, on-inverse — whose colour is the meaning.

**Nothing else paints text.** A `color` declaration is an ink role, a family's TONE ink (§8), a
shadcn surface pair (`--card-foreground` and friends, which say "the ink for THAT ground"),
`--lotics-accent` (a link, an active affordance) or `--lotics-white` (a glyph over imagery, which
does not move with the theme).

**`muted` is bounded at both ends.** Its floor is AA on every ground the kit paints it on. The
ceiling is what makes supporting text RECEDE: at 7.7 : 1 a caption reads as a second body.

**Type.** Seven size rungs, `xs` … `xxxl`, the last four growing at 768px, and **there is nothing
between two rungs** — a 13 is a rung the ramp does not have (`theming.md` §2.3). A caption is the `sm` rung on `muted`, `xs` on a row's supporting lines and where nothing
else divides a pair (`CellStack`). A UI label takes `tight` leading; `Text` DEFAULTS to the PROSE leading, because a paragraph and the
same paragraph rendered from markdown must sit at one rhythm. Figures are **semibold** where they
are the point.

*Checked by* `text_ink_contrast.test.ts` (both ends, both themes, four grounds each),
`css_contract.test.ts` (`text-ink`) over the sheets, and `style_props.test.ts` over the PROPS — a
kit colour prop is typed to the roles unless its registry row says the value comes from data, so a
shade does not compile; `css_kit_reach.ts` refuses one an app writes at a call site.

## 4. Numerals — lining and tabular, everywhere

```css
--lotics-font-numeric: lining-nums tabular-nums;
```

`reset.css` states it on every `lotics-` element, so a page that disagrees cannot reach inside a
component. Old-style figures descend below the baseline — wrong for an id, a quantity and a KPI.

**The token is the only writer.** A `font-variant-numeric` declaration takes
`var(--lotics-font-numeric)` and nothing else — the property is not additive, so a rule that says
`tabular-nums` on its own resets the figure value to `normal` and drops the `lining-nums` half.
`font-feature-settings` may not name `onum`, `pnum`, `lnum` or `tnum` either: that is the same
declaration in another language.

*Checked by* `css_contract.test.ts` (`numerals`) in the kit, `ui-internal`, the frontend and the
gallery document.

## 5. Radius — four rungs, by ALTITUDE

| Rung | Is | At the default `--radius` |
|---|---|---|
| `--lotics-radius-control` | `var(--radius)` | 10 |
| `--lotics-radius-card` | `calc(var(--radius) * 1.4)` | 14 |
| `--lotics-radius-overlay` | `calc(var(--radius) * 2)` | 20 |
| `--lotics-radius-full` | `9999px` | a pill |

Picked by what the thing IS, never by how big it is. **Proportional to `--radius`**, as shadcn
derives `--radius-sm/md/lg/xl` (× 0.6 / 0.8 / 1 / 1.4), so a tweakcn export scales this ladder and
`rounded-*` alike; `card` IS shadcn's `xl`.

A corner is one of the four, a CONCENTRIC inset off one
(`calc(var(--lotics-radius-control) - var(--lotics-space-2))`, which is geometry: a thing nested
2px inside a rounded control), or a proportion of the component's OWN box
(`proportionalRadius`, height × 0.3 capped at the control rung — nothing sized by data is rounder
than the thing you press), which the component's `.tsx` computes and writes to a property the sheet
does not declare. **A fraction of a rung is refused**: `× 0.3` of a radius is a proportion of
nothing.

**Every spelling counts, and a parked value is followed.** The eight longhands are the shorthand's
rule; a `--lotics-x-radius: 3px` a sheet names in a corner is that 3px.

*Checked by* `css_contract.test.ts` (`radius-ladder`, `type-literal`).

## 6. Surfaces — the flat idiom, in numbers

**A region boundary is whitespace, or ONE hairline (`--border`) — never both a border and a
shadow.** A shadow belongs to ALTITUDE: a surface that floats over the page has one and needs no
hairline; everything on the canvas has the hairline and no shadow. A ring — `0 0 0 1px`, no offset,
no blur — is an edge drawn with the one property that reaches outside the box, and is not a shadow.
Read over the ELEMENT, not the rule: a border in one rule and a shadow in another is one surface
asserting its edge twice, and `filter: drop-shadow()` is that altitude in another property.

**A border belongs to an INTERACTIVE item.** A group of things that belong together is grouped by
space.

**Every child of a flex row states its floor.** A flex item's automatic minimum is its MIN-CONTENT
width, so a text column does not shrink past its longest word: it pushes the container wider and
paints off screen, invisible at desk width. `min-width: 0` says the column may give way;
`min-content`, a width or `flex-shrink: 0` says it owns its width. A rule stating neither has not
decided — and where one child gives way it takes the WHOLE deficit, so a silent sibling crushes it.
**Which sibling a floor keeps is decided by
ALTITUDE**: a section heading floors its title and the verb wraps; a page band keeps its verbs and
the title wraps.

**The washes are the ladder, and they are alpha of `--foreground`** so they composite over whatever
ground the row sits on:

```
--lotics-wash-hover           2%
--lotics-wash-selected        5%
--lotics-wash-press          12%
--lotics-wash-control-hover  12%   /* = press */
--lotics-wash-control-press  19%
```

A control's own hover is its row's press rung: the control pair is the row ladder one rung up. The
rungs must stay apart: hover is
transient and selection is state, so a surface painting both in one ground erases its only state.

*Checked by* `css_contract.test.ts` (`flat-surface`, `flex-floor`, `state-ground`) in the kit and
`ui-internal`; the altitude by `heading_floors.test.ts`.

## 7. Type budget — three rungs and two weights per sheet

A fourth size or a third weight is a distinction no reader can hold — hierarchy the layout should
carry. Counted as the
distinct values RESOLVED over the whole sheet — one `font-size: var(--lotics-x-size)` and four rules
setting that property renders four sizes. `Text` is exempt — it publishes the ramp — and so is
rendered prose, which spans the heading ladder by construction.

*Checked by* `css_contract.test.ts` (`type-budget`).

## 8. One accent per screen

**One saturated hue.** Everything else a screen paints is neutral, or it is a STATUS — the valence
families, whose colour is the meaning rather than the brand. A series ramp and a state tint are
THAT hue at a lower strength: the moment a screen holds two, neither means anything.

**One exception: an IDENTITY MARK's hue is data — WHO — dealt from eight families by a hash of the
subject, never picked at a call site.**

**A family's colour is a ROLE the theme resolves** — wash, wash with body, ink, solid — and a rung
reaches a sheet or a `style` only through one of the four. The JS palette (`colors`, `ramp`) is the
DATA path — a chart series, an option's colour — and each use of it is DECLARED.

*Checked by* `accent_budget.test.ts`, over every colour a component falls back to when the caller
states none; the marks by `identity_hue.test.ts`,
which measures every dealt pair; the tones by `tone_register.test.ts`, which fails one that
does not invert, and by `palette-rung` over the kit's and `ui-internal`'s sheets and `.tsx`.

## 9. Glyphs and marks — sized by the text beside them

```
glyph  14   16   20        stroke 1.75 below 16, 1.5 at 16 and above
mark   16   24   28   40   56   72
```

An icon is read against the type beside it: 14 under a 12px label, **16 in a row or a control**,
20 by a heading or alone; a size its component derives is free.

**A leading mark is the height of the text it marks** (`identityRung`): a name 28, a name and
its line 40, a profile 72, a dense cell 24, a line 16, a datum 24. **A list's leads share ONE
slot** (`leadSlot`): 56 where a row holds a picture or document, else 40, and before a phone cuts a
name. **Only a picture fills it** (`markInSlot`): a document is a TILE, its page inset
(`tileBadge`); one not held, dashed — a picture only beside one held; a face 40.
**Initials are someone's.**

**Colour is an ink role** (`InkColor`): rule 3's ladder, the enclosing surface's pair, the accent,
or the white that does not move with the theme — nothing else paints a glyph.

*Checked by* `iconSizeViolations`, run by `css_contract.test.ts` in the kit, `ui-internal` and the
frontend; the stroke by `layout_leaves.test.tsx`; the marks by `identity_rung.test.tsx`.

## 10. Width — CSS answers it, JS answers anatomy

Two thresholds, in three languages that must agree:

```
small < 768 ≤ medium < 1728 ≤ large
```

`deriveSizeBuckets` in JS, `@media (width < 768px)` for the window, and
`@container lotics-boundary (width < 768px)` for a box. **A response that is a class, an attribute
or a length is a query.** Only a component rendering a DIFFERENT TREE at the two widths — a popover
that becomes a modal sheet, a badge that renders nothing where there is no keyboard — reads a
bucket in JS, and a query has no server seam: `useScreenSize` returns the same constant on both
sides of hydration, so a JS answer paints the desk layout on a phone until the script runs.

**`lotics-boundary` is the container and `SizeBoundary` declares it**, on a box that OWNS its width
— a rail, a dialog body, a resizable panel. Inside a box sized by its own contents
(`width: max-content`) inline-size containment zeroes that contribution and the box collapses.
Such a subtree's width is a JS fact and `ContainerSizeProvider` publishes it. **A component
contains its OWN box, never the caller's** — a row, a chart, a page shell fills whatever it is
given, while a container wrapped around content the caller passed in collapses every ancestor
sized by that content, and no query asks whether the caller's box owns its width. A `SplitPane` is
therefore not a boundary; a caller who wants one inside a pane writes it. Containment does NOT
move a `position: fixed` descendant — `container-type` applies layout containment without the
containing-block half.

**A container query pairs with a media query, in that order.** With no boundary in the tree the
WINDOW is the container — `useContainerSize()`'s own contract — and `@container` on a name nothing
declares is false, so the `@media` block states the no-boundary answer and two `@container` blocks
after it override it both ways.

*Checked by* `primitives_purity.test.ts` (the anatomy allowlist: a kit entry reading a bucket names
the tree it renders instead, and a boundary mount names the width its surface states) and
`css_contract.test.ts` (`width-rule`), which runs over the kit's sheets and the app's alike, and
where every container declaration names the box it fills.
