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.
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.
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). 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) | 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.
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.
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. Colour roles and the contrast table: Theming. Seeing a component in a state you cannot click to: Testing.