# 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.

Read this before the other docs. `theming.md` says how a value reaches the screen — which variable,
declared in which scope, and what a theme sets to move it; `composition.md` says which shape
carries which meaning.

**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
```

The JS type takes the same numbers: `gap={12}`, `padding={16}`, `spacing={8}`. A rung IS its name.

**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 (`calc(var(--lotics-space-8) + var(--lotics-space-4))` is 12, and 12 is a rung)
and a rung may not be scaled (`* 1.5` is a length on no scale, and `* -1 * 2` is that again with a
direction in front of it). 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))`, or a popup's
`calc(200px + 2 * var(--lotics-action-menu-inset))`, whose whole point is that the floor tracks the
inset. 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. The JSX
numbers in `examples/tpl_*.tsx` and the gallery are read by the same budget, because
a reference that writes `padding: 20` teaches 20.

**There is no eleventh rung and none above 48.** A gap larger than 48 means the two things belong on
separate 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.

**6, 10 and 12 are CONTROL INTERIORS and exist for nothing else** — a chip's inset, the gap beside
an identity mark, a dense row's block padding. A region never takes one. **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 centred 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, so a pressable cell's whole box is the target); taller content grows it. Between columns
there are two, by what the cells hold: words take `-inset` (12), a grid of glyphs `-cell-gap` (2).

*Checked by* `tokens_agreement.test.ts`, which reads both pairs and the band off the sheet, and
`css_contract.test.ts` (`register-band`) in the kit and `ui-internal` — a register composes no
band of its own, as a height, as block padding or as a square.

## 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.** The floor is AA on every ground the kit paints it on — the
canvas, the card, and the hover and selected washes over each. 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. The values are `theming.md` §2.3. A
caption is the `xs` rung on `muted`. 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, which is right for a book and wrong for an
id, a phone number, 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` and the frontend, and
over 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`, the way shadcn's
own `globals.css` derives its `--radius-sm/md/lg/xl` (× 0.6 / 0.8 / 1 / 1.4)**, so a tweakcn export
scales this ladder and a consumer's `rounded-*` utilities identically; `card` IS shadcn's `xl` and
`overlay` takes one more step of the same ratio.

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 same rule as
the shorthand, and a `--lotics-x-radius: 3px` the sheet declares and then names in a corner is that
3px, wherever it is written.

*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 size is its
MIN-CONTENT width, so a column holding text 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 that states
neither has not decided, and the browser decides for it — and where one child gives way it takes
the WHOLE deficit, so a silent sibling crushes it.

**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%
```

Reaching a control means crossing its row, so a control's own hover is the row's press rung, and
the control pair is the row ladder shifted 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 piece of
persistent state.

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

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

A component that reaches for a fourth size or a third weight is not making a distinction a reader
can hold; it is spending the ramp on hierarchy the layout should be carrying. 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 the `tpl_*` references — they are what an app
copies, so a template that names four families teaches four; 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 — three sizes, and a stroke that follows

```
14   16   20        stroke 1.75 below 16, 1.5 at 16 and above
```

An icon is read against the type beside it, so it has as few sizes as the type has weights: 14
under a 12px label, **16 in a row or a control** (the default), 20 beside a heading or as a lone
affordance. A size DERIVED from a measure the component owns — a badge's glyph as a fraction of
the badge — is that component's drawing and composes freely, the same escape a `calc()` gets from
rule 1.

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

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

## 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 simply false, so the `@media` block states the no-boundary answer and two `@container`
blocks after it override it in both directions.

*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.
