# Interface rules

What a screen must do, as rules an agent can apply without judgement — and, where judgement is
unavoidable, marked as judgement so it is not mistaken for a check.

`As of 2026-09`.

**Read the second column before the first.** Every rule is one of two kinds:

| Column says | Means |
|---|---|
| a mechanism | a build error. The named guard, type or gate step refuses it. Break it and `x verify` is red |
| **judgement** | nothing checks it. It is here because an agent still has to follow it, and because axiom 3 says an unenforced convention does not exist — so this page says which ones do not |

The mechanisms are of three shapes, and knowing which one you hit tells you where the fix goes:

| Shape | Where it fires | Example |
|---|---|---|
| a **type** in `@ultimat3/ui` | `x verify`'s `typecheck` step | `AsyncRegionProps.empty` is required |
| a **guard** in your app's `guards/` | `x verify`'s `boundaries` step | `guards/focus-visible.ts` |
| a **test** in the framework | `x verify`'s `unit` step | `packages/ui/src/tokens/contrast.test.ts` |

Guards are yours: `x new` writes every name in `SHIPPED_GUARD_NAMES`
(`packages/cli/src/templates/scaffold-guards.ts`), the directory is the registration, and deleting
a file deletes the rule. Add one with `x g guard <name>` — a name the framework ships a guard under writes
that guard, any other name writes a blank one — and `x doctor` lists the shipped ones an app does
not hold. Each raises an app-owned `X_*` code derived from
its own filename — `guards/focus-visible.ts` raises `X_FOCUS_VISIBLE` — so a finding says which
convention broke, not which framework subsystem noticed.

## Loading, empty, error

| Rule | Refused by |
|---|---|
| Every region that waits on data has four branches: pending, failed, empty, ready | `AsyncRegion` renders all four; a hand-rolled region is **judgement** |
| `empty` is unreachable while pending | the `AsyncBranch` union, `packages/ui/src/components/async-branch.ts` — `pending` carries no data, so nothing can be found empty in it |
| Declare what "nothing here" looks like | `AsyncRegionProps.empty` is a required prop with no default |
| A refetch keeps the previous data on screen | `asyncStateOf` — `loading` beside a defined `data` is `refreshing`, never `pending` |
| A failure beats stale data: report the error, do not re-render rows the failed request did not answer | `asyncBranch` tests `failed` first |
| Placeholder, error, empty and content land in the same box | `reserveBlockSize` is emitted as `--async-reserve` on the region in every branch |
| The region carries `aria-busy` while in flight | `AsyncRegion` derives it from `isBusyBranch` |
| `data: undefined` means nothing has arrived. An empty array is data | `asyncStateOf` |
| A `{ total: 0, items: [] }` envelope passes its own `isEmpty` — `isEmptyData` does not guess at an envelope | `AsyncRegionProps.isEmpty` |

Rendering "No results" for one frame before the first page arrives is the most common
agent-authored UX bug in a list screen. The union above is why it is unconstructible rather than
discouraged.

## Skeletons and spinners

| Rule | Refused by |
|---|---|
| A skeleton only where the shape is known, stable, and the wait is 1–3 seconds | **judgement** |
| The placeholder box equals the loaded box | `AsyncRegion` feeds one `reserve` to `Skeleton` and to the content box |
| A skeleton is never announced | `Skeleton` is `aria-hidden="true"`; the region's `aria-busy` is the announcement |
| A spinner has an accessible name | `Spinner` is `role="status"` with `aria-label`, defaulting to the `ui.loading` catalog key |
| A decorative spinner inside an already-announced region is hidden | `SpinnerProps.decorative` |
| Never a skeleton for an unknown-length list, a variable-height card, or content whose shape depends on the response | **judgement** |

**The evidence runs against skeletons, not for them.** A controlled study (Viget, n = 136,
durations matched between conditions) found a skeleton screen lost to a plain spinner on every
measure taken, perceived wait included — **2.82s** perceived under the skeleton against **2.41s**
under the spinner. Treat "use a skeleton" as a claim needing a reason, not a default. A skeleton
whose box does not match the loaded content is strictly worse than a spinner: it converts a
perception problem into a layout-shift problem, and layout shift is measured.

## Response tiers

Nielsen's three limits. All four rows are **judgement** — nothing in the gate times a response.

| Elapsed | Show |
|---|---|
| under 100ms | nothing. The result is the feedback. A spinner here reads as a stutter |
| under 1s | still nothing. Thought is unbroken; a spinner that appears and vanishes is noise |
| 1–10s | an indeterminate busy indicator. Keep the trigger visible and disabled, not replaced |
| over 10s | determinate progress **and** a cancel. Attention is gone at 10s; a bar with no cancel is a hostage |

Never a spinner on a control whose action completes locally. Never a determinate bar over a
percentage you are inventing.

## Optimistic UI

Correct only when **all three** hold: the operation is idempotent, its result is predictable from
the input alone, and it targets the same URL.

| Rule | Refused by |
|---|---|
| Never optimistic on money, a balance, stock, a quota, or a permission grant | **judgement** |
| The optimistic twin is a pure function of `(tx, input)` — no I/O, no `Date.now()`, no `Math.random()` | **judgement** in an app. `local` is replayed on every rebase, so an impure one diverges silently |
| The twin is convergent, not incremental: applying it N times equals applying it once | **judgement**. `likedByMe ? {} : { likeCount: n + 1 }` is the shape; `likeCount + 1` is the bug |
| Rollback is written, never assumed | the local store journals the first write per key and undoes newest-first — but only when a `LocalStore` is wired. Without one, nothing rolls back |
| A refused mutation is rolled back, not retried | `@ultimat3/realtime` drops the refused intent from the rebase log |

Full shape and the conflict strategies: [Realtime](/realtime#same-mutator-at-every-rung).

## Forms

| Rule | Refused by |
|---|---|
| A label is a real `<label for>`, never a placeholder and never an adjacent `<span>` | `FieldProps.label` is required, and `Field` renders `<label for={id}>` |
| `aria-describedby` and `aria-invalid` cannot drift from what is rendered | `Field` owns the ids and hands them to the control through `FieldControl` |
| Never block paste | `InputProps` declares no `onPaste` and `@ultimat3/ui` does no prop spreading, so there is no way to attach one through `Input` |
| No `type="number"` | the `InputType` union has no `'number'` member — a numeric field is `inputmode` plus a text type, so a locale decimal separator survives |
| `type`, `inputmode` and `autocomplete` on every input that has a right answer | **judgement** — the props exist, nothing requires them |
| One field per row. Two side by side only when they are one value (expiry month + year) | **judgement** |
| An error is an instruction: what to type, not what is wrong | **judgement** |
| The error text is announced with the control it belongs to | `Field` wires `error` into `aria-describedby` |
| Choosing one of a list: `Select` for a short closed list, `SearchableSelect` for a long one whose label is not its value, `Combobox` for free text with suggestions | one control per question; `SearchableSelect` posts hidden inputs, so typed text never reaches the server, and without script it is the native `<select>` |
| A chart's colour says what the caller said it means: `StatTile goodWhen="down"` for an error rate | `deltaMood` — the arrow carries the direction, the colour only a stated meaning; `goodWhen="none"` colours neither |
| A gap is a gap: never zero-fill a missing value in a chart | `ChartPoint.value` and every series value take `null`; a chart draws nothing there and the table reads `ui.chart.none` |

**16px is the floor for a touch-reachable text input.** iOS Safari zooms the viewport on focus
below it, and the zoom does not undo itself. The default control is safe — the `control` mixin sets
`font-size: var(--text-md)`, whose `clamp()` floor is `1rem` — but **`size="sm"` is not**:
`--text-sm` floors at `0.875rem` = **14px**, and `.input` is `font: inherit`. Nothing checks this.
Treat `<Input size="sm">` as desktop-only.

## Motion

| Rule | Refused by |
|---|---|
| Animate `transform` and `opacity`. Nothing else | `guards/animated-layout-property.ts` |
| Never `transition: all` | same guard |
| A `transition` naming no property **is** `all` — name the properties | same guard, which reports the omission as the implicit `all` it is |
| No layout property inside `@keyframes` | same guard |
| Reach for `t.duration()` / `t.easing()`, or `@include t.transition(<props>)`, never a literal `220ms` or a raw `cubic-bezier` | **judgement** — the scales are in `packages/ui/src/tokens/_motion.scss` and nothing refuses a literal |
| Reduced motion is honoured globally, not per component | `tokens/reset.scss` |

**Reduce means reduce — but the shipped global rule deletes.** `reset.scss`'s
`@media (prefers-reduced-motion: reduce)` sets `animation-duration` and `transition-duration` to
`0.01ms !important` on `*`, so a component that wants a cross-fade in place of movement has to win
that cascade itself: its own `prefers-reduced-motion` block, with `!important`, on a selector more
specific than `*`. Replacing movement with a cross-fade is the rule; getting it past the global
guard is work, and if you skip it the feedback is gone rather than calmed.

Never remove the feedback. A state change with no motion still needs a state change the reader
can see.

## Accessibility

| Rule | Refused by |
|---|---|
| A click is answered by a control, never by a `div` with a handler | `guards/semantic-interactive.ts` — `onClick`/`onMouseDown`/`onKeyDown` on an inert tag |
| Never `role="button"`, `role="link"` or `role="checkbox"` where the native element exists | same guard, which names the element to write instead |
| A role plus `tabindex` plus a key handler is the complete set the ARIA practices guide asks for, and is the one shape the guard passes over | same guard |
| The focus ring is replaced, never only removed | `guards/focus-visible.ts` — `outline: none\|0` with no indicator in the rule or within 600 characters of it |
| `@include t.focus-ring` is the one form | the mixin emits `:focus-visible` and the mouse-only `:focus:not(:focus-visible) { outline: none }` together; the guard recognises the include and never reads inside it |
| `:focus:not(:focus-visible) { outline: none }` is the correct idiom, not the defect | the guard never reports it |
| An icon-only control has a name | `IconButtonProps.label` is required — there is no unlabelled icon button |
| An icon with no `label` is decorative and hidden | `Icon` emits `aria-hidden="true"` unless given one |
| A live region exists before the message does | `AppShell` renders both `liveRegionAttrs` regions into the server response |
| One announcement path per message: `announce()` for a state change with no surface, a toast for a notification | **judgement** — two paths make a screen reader read it twice |
| Text meets 4.5:1 and the focus ring 3:1, in **both** themes | `packages/ui/src/tokens/contrast.test.ts`, `x verify`'s `unit` step. Shipped pairings only |

**No ARIA is better than bad ARIA** — the WAI-ARIA Authoring Practices Guide's own line, and the
measurement behind it: WebAIM's annual survey of a million home pages finds pages using ARIA
average **~41% more** detected errors than pages using none. `role="button"` is a promise the
element then owes — Space, Enter, focus order, a disabled state — and only the native element keeps
it for free.

**`announce()`'s first message can be silent.** A live region created and filled in the same frame
is not announced by most screen readers. `AppShell` renders the regions server-side so the first
message is safe; the create-if-absent fallback in a tree with no shell is right from its second
call onwards. Render `AppShell`.

**A brand override is contrast-checked where it is declared.** `defineTheme()` measures every pair
in `CONTRAST_PAIRS` that the brand changed, either side, on the resolved palette — 4.5:1 text, 3:1
the focus ring, 1.4:1 `line` — and throws `X_UI_CONTRAST_INSUFFICIENT` before rendering anything.
Declared at module top level, a failing brand stops the app at import, not in review. Measure a
candidate first with `contrastRatio` or `roleContrast` from `@ultimat3/ui` → [Theming](/theming).

## Toasts

| Rule | Refused by |
|---|---|
| Confirmations only. Never the only copy of information | **judgement** |
| Never the only affordance for an action | **judgement**, plus the shape below |
| At most one control, and it is undo-shaped | `ToastAction` is a single optional object, not a list — not taking the offer is the status quo |
| Dwell is a token, never a number at the call site | the `ToastDwell` union — `'short' \| 'long' \| 'sticky'`; a call site cannot pass `6500` |
| A `sticky` toast still carries a dismiss control | **judgement** |
| Never take focus | `ToastRegion` sets no `tabindex` and moves focus nowhere; it pauses the dwell on `focusin` instead |
| The dwell pauses on pointer, on focus, and on a hidden tab — three independent reasons, never one boolean | `ToastHold` |
| At most three on screen; the rest queue and start their dwell when they surface | `TOAST_MAX_VISIBLE` |
| The list announces the arrival, not the whole list | `ToastRegion` is `aria-atomic="false"` on the `<ol>`, and the `<ol>` — not each toast — owns `aria-live` |

A toast disappears on a timer. A keyboard user who was typing when it arrived never reached it.

## Images

| Rule | Refused by |
|---|---|
| Every image declares its box: `width` **and** `height`, or an `aspect-ratio` | `guards/image-dimensions.ts` |
| One dimension alone is a refusal, not a half-measure | `boxFor` throws `X_UI_INVALID_VALUE` — one alone reserves no aspect ratio |
| The LCP image is never lazy | `guards/image-dimensions.ts` reports `loading="lazy"` on a `priority` / `fetchpriority="high"` image; `<Image>` derives `loading` from `priority` and does not take it as a prop |
| `alt` is required. `alt=""` is a decision, made deliberately | `ImageProps.alt` has no default |
| At most one `priority` image per route | **judgement** — nothing counts them |
| `<Image>` fabricates nothing: no rendition it was not given, no dimension it did not measure | the component emits exactly what it is handed |

An unsized `<img>` is the largest single contributor to Cumulative Layout Shift: the page reflows
under the reader's finger the moment the bytes land.

## Islands

| Rule | Refused by |
|---|---|
| Every `*.island.tsx` has a sibling `*.island.states.ts` | `guards/island-without-states.ts` |
| A states file is pure data — no sibling import, no framework import | `assertIslandStatesPure`, at `x shot` load time |
| An island polls with `visibleInterval()` from `@ultimat3/core/page`, never a bare `setInterval(`: a hidden tab and a lost session stop it ([Client data](/client-data)). A `// why:` on the line, or the line above, admits a tick that is not a read | `guards/raw-interval.ts` |
| A destructive act asks through `ConfirmDialog` from `@ultimat3/ui` — Cancel focused, Esc closes, the backdrop never, a native post with scripting off, a refusal kept open and focused — never `window.confirm(`. A `// why:` on the line, or the line above, admits a call that is not that question | `guards/native-confirm.ts` |
| A channel's "this changed" re-reads a server-rendered page through `scheduleRefresh()`, never `refresh()`: nothing swaps under a reader ([Realtime](/realtime#a-channel-event-re-reads-the-page)) | **judgement** |
| Every state carries a `note` saying why it cannot be reached by clicking | **judgement** — `note` is optional, and it is the line that tells a reviewer what they are looking at |

An island with no states file has never been seen with a refused save, an empty read, or a label
three times as long in the next locale — not by a reviewer and not by a model. How to photograph
them: [Testing](/testing#seeing-what-you-built).

## Lists and pickers

`As of 2026-11`.

| Rule | Refused by |
|---|---|
| Which list: records with columns `DataTable`; thousands of one-shape rows `VirtualList`; nesting `Tree`; what happened `Timeline`; facts about one thing `DescriptionList` | **judgement** — there is no activity-feed component to reach for instead: `Timeline` + `InfiniteScroll` + `RelativeTime` |
| A long list is a real `<ul>` that renders every row with scripting off; with script it mounts what is on screen | `VirtualList`'s server branch (`lists.test.ts`); under 100 of 10,000 mounted in Chrome (`ui-virtual-list.e2e.test.ts`) |
| A live prepend moves nothing being read, and keeps the focused row's node and focus | `ui-virtual-list.e2e.test.ts`; a repeated `itemKey` is `X_UI_INVALID_VALUE` |
| A `Tree` of links is `<details>` and links, 0 B; `role="tree"` only with `onSelect`, one tab stop | the `TreeProps` union; `lists.test.ts` |
| A date control is the native input first; the calendar is an extra on a fine pointer, and Esc returns focus to its opener | `DatePicker`/`DateRangePicker` (`pickers.test.ts`, `ui-date-pickers.e2e.test.ts`) |
| A window takes an explicit IANA zone | `TimeRangePickerProps.timeZone` is required; a non-IANA zone is `X_TIMEZONE_INVALID` |
| Window presets are the app's, never the framework's | `timeRangeUrl` ships no list; a preset with neither `seconds` nor `from`/`to` is `X_UI_INVALID_VALUE` |

## Reordering and viewers

`As of 2026-11`.

| Rule | Refused by |
|---|---|
| A drag is Pointer Events with capture from a handle; never HTML5 drag events | `drag/pointer-drag.ts` is the one gesture; `ui-drag.e2e.test.ts` (keyboard, pointer, rtl) |
| A drop is a request: `onMove` once, the row held until it settles, a rejection restores and is announced | `sort-controller.test.ts`, `drag-interaction.test.ts` |
| Reordering works by keyboard alone and with scripting off | Space/arrows/Esc in `drag-interaction.test.ts`; the per-row `<form>` in `posts/board/page.test.ts` |
| A business rule on a drop is `canDrop`, never a fork | **judgement** — the one seam the components take |
| Text a model or member wrote renders as a tree, never HTML; only http/https/mailto/relative links | `viewers-no-html.test.ts`, `markdown-parse.test.ts`, no alert under CSP in `ui-viewers.e2e.test.ts` |
| A remote image in a body is a link to its alt unless `resolveImage` reserves its box | `viewers.test.ts` |
| An island passes only the languages it shows | `ui-barrel-bytes.test.ts`: `Markdown` alone retains no `languages/*` |
| A diff reads with colour off: `+`/`-` gutters and a spoken word per changed row | `viewers.test.ts` |

## Stylesheets

Biome does not read `.scss`. These seven guards are the only thing that does. `As of 2026-10`.

| Rule | Refused by | The `fix:` it names |
|---|---|---|
| A length is a token or `rem()`. `1px` hairlines and `0` are not lengths anyone scales | `guards/raw-length.ts` — a `px` in a declaration, an `@include` argument or an inline `style` | the declaration to write: `padding: tokens.space(3)`, `font-size: tokens.rem(14px)`, `border: tokens.stroke(thick) solid …` |
| A viewport query names a rung of the one ladder | `guards/raw-breakpoint.ts` — `@media` with a `width` feature | `@include tokens.respond-to(md)` / `respond-down(md)` / `respond-between(md, lg)` |
| A layer is a name | `guards/raw-z-index.ts` — a number in `z-index` | `z-index: tokens.z(dropdown)`; behind its own stacking context, `calc(-1 * #{tokens.z(raised)})` |
| Elevation comes off the themed shadow scale | `guards/raw-shadow.ts` — a hand-written length in `box-shadow`, `text-shadow` or `drop-shadow()` | `box-shadow: tokens.shadow('sm')`, or `@include tokens.surface` |
| One tempo | `guards/raw-motion.ts` — a duration or `cubic-bezier()` literal in `transition*` / `animation*` | `transition: opacity tokens.duration(fast) tokens.easing(out)` |
| A class a component reads is a class its sheet compiles | `guards/undefined-style-class.ts` — `styles.x` / `styles['x-y']` against the COMPILED `.module.scss` | the nearest class the sheet declares, or the rule to add |
| A custom property a sheet reads is one the app declares | `guards/undeclared-custom-property.ts` — `var(--x)` in the compiled CSS with no declaration anywhere and no fallback | the steps the scale really has: `tokens.space(7)` → `0 \| 1 \| 2 \| …` |

| Not a finding | Why |
|---|---|
| `$gutter: 12px`, a map entry, a parameter default | a definition names a value in one place — which is what a token is. A use is reported |
| `tokens.rem(340px)`, `tokens.fluid(16px, 32px)` | the sanctioned spelling of an off-scale length |
| `@media (prefers-reduced-motion: …)`, `print`, `(hover: hover)`, `@container` | not a viewport width |
| `inset 0 0 0 1px …` | a hairline ring is not elevation |
| `linear`, `ease`, `0s`, `calc(#{tokens.duration(slower)} * 2)` | a keyword, a zero, a multiple of a token |
| `styles[name]` | a computed key is only known when the component runs |
| `var(--x, fallback)`; a property a `.ts`/`.tsx` file names | it declares its own answer; an inline `style` sets it |
| a sheet that does not compile | the build's refusal (`X_PRERENDER_FAILED`), not a guard's |

## Data access

One shipped guard is not about the interface. Same contract — a file in `guards/`, a finding with
a `fix:` — documented where its subject is: [Entities and migrations](/entities-and-migrations#the-repo).
`As of 2026-10`.

| Rule | Refused by | The `fix:` it names |
|---|---|---|
| A `repo.ts` reads through the typed handle | `guards/repo-raw-sql.ts` — a `sql` literal the handle can express: `select *`, `insert`, `update` or `delete` on one table, plain comparisons | the handle call: `db.posts.where({ orgId }).orderBy('createdAt', 'desc').limit(limit).all()` |

| Not a finding | Why |
|---|---|
| a join, an aggregate, a CTE, a window, an `or`, a function call | the handle has no word for it; raw SQL in the repo is the answer |
| a statement composed from another `sql` fragment | composition, not one statement |
| `sql` in a `query`'s source, a migration, a comment or a string | only a tagged template in a `repo.ts` is read |

A sheet that defines its own `@function rem` / `fluid` or `@mixin respond-*` is refused by
`raw-length` and `raw-breakpoint`: Sass lets a module that forwards the tokens redefine one with no
error, and the local copy silently wins.

## Anti-patterns to flag

Read as a review checklist. Each one is a defect on sight.

| Flag | Why |
|---|---|
| `<div onClick={…}>` | a keyboard, a screen reader and a switch reach it in exactly one way, which is not at all |
| `role="button"` on anything but `<button>` | a role is a promise; the native element is the only thing that keeps it |
| `outline: none` with nothing painted back | deletes the only thing telling a keyboard user where they are |
| `transition: all` | animates properties nobody chose, including ones not yet written |
| `transition: 220ms ease` with no property named | an omitted property **is** `all` |
| `<img>` with no `width`/`height` and no `aspect-ratio` | every element under it jumps when the bytes land |
| `loading="lazy"` on the hero image | a deliberate delay on the one paint the metric measures |
| "No results" rendered from a pending state | the four-state bug; `empty` must be unreachable before a result arrives |
| a skeleton whose box differs from the loaded content | a layout shift with extra steps |
| a spinner on a sub-100ms action | reads as a stutter |
| a progress bar with no cancel past 10s | attention is already gone |
| an optimistic write to a balance, a stock count or a permission | the rollback is visible and it is the wrong number that is remembered |
| `onPaste` blocked on a password field | it makes password managers unusable and buys nothing |
| `type="number"` for a quantity or an amount | locale decimal separators do not survive it |
| a 14px input on a touch surface | iOS zooms the viewport on focus and does not zoom back |
| a toast holding the only "Undo" for a destructive action | it expires on a timer |
| a toast that moves focus | it interrupts typing to report something that was not asked for |
| a live region created in the same frame as its first message | it is not announced |
| an icon-only button with no `label` | silence to a screen reader |
| a `*.island.tsx` with no states file | nothing has ever seen it fail |
| a literal `cubic-bezier(…)` or `220ms` in a component stylesheet | the scale exists so the curve can be retuned in one file |
| a raw hex in a component stylesheet | it does not flip with the theme |
| a Previous button on a keyset list | a `Page` has no previous cursor, so it is disabled forever; `Pagination` draws First (`firstHref`) instead |
| a list sorted or paged through an island when a link would do | `listUrl` + a link-mode `DataTable` sort and page with scripting off |

## Where each mechanism lives

| Mechanism | File |
|---|---|
| the four-state union | `packages/ui/src/components/async-branch.ts` |
| the motion scales | `packages/ui/src/tokens/_motion.scss` |
| the space, stroke, breakpoint, z and shadow scales | `packages/ui/src/tokens/_space.scss`, `_stroke.scss`, `_breakpoints.scss`, `_z.scss`, `_shadow.scss` |
| the global reduced-motion guard, and the default focus ring | `packages/ui/src/tokens/reset.scss` |
| `focus-ring`, `control`, `transition`, `visually-hidden` | `packages/ui/src/tokens/_mixins.scss` |
| contrast, as a failing test | `packages/ui/src/tokens/contrast.test.ts` |
| live regions and the focus trap | `packages/ui/src/a11y.ts` |
| the toast queue's rules | `packages/ui/src/toast/toast-state.ts` |
| a list's URL state (sort, cursor, limit, `q`) | `packages/ui/src/list-url.ts` |
| your app's guards | `guards/` in the app root — `x new` writes every name in `SHIPPED_GUARD_NAMES`, `x g guard <name>` adds one, `x doctor` lists the shipped ones missing |

Components, props and token vocabulary: [UI components](/ui-components). Colour roles and the
contrast table: [Theming](/theming). Seeing a component in a state you cannot click to:
[Testing](/testing#seeing-what-you-built).
