Ultimate

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 saysMeans
a mechanisma build error. The named guard, type or gate step refuses it. Break it and x verify is red
judgementnothing 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:

ShapeWhere it firesExample
a type in @ultimat3/uix verify's typecheck stepAsyncRegionProps.empty is required
a guard in your app's guards/x verify's boundaries stepguards/focus-visible.ts
a test in the frameworkx verify's unit steppackages/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

RuleRefused by
Every region that waits on data has four branches: pending, failed, empty, readyAsyncRegion renders all four; a hand-rolled region is judgement
empty is unreachable while pendingthe 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 likeAsyncRegionProps.empty is a required prop with no default
A refetch keeps the previous data on screenasyncStateOf — 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 answerasyncBranch tests failed first
Placeholder, error, empty and content land in the same boxreserveBlockSize is emitted as --async-reserve on the region in every branch
The region carries aria-busy while in flightAsyncRegion derives it from isBusyBranch
data: undefined means nothing has arrived. An empty array is dataasyncStateOf
A { total: 0, items: [] } envelope passes its own isEmpty — isEmptyData does not guess at an envelopeAsyncRegionProps.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

RuleRefused by
A skeleton only where the shape is known, stable, and the wait is 1–3 secondsjudgement
The placeholder box equals the loaded boxAsyncRegion feeds one reserve to Skeleton and to the content box
A skeleton is never announcedSkeleton is aria-hidden="true"; the region's aria-busy is the announcement
A spinner has an accessible nameSpinner is role="status" with aria-label, defaulting to the ui.loading catalog key
A decorative spinner inside an already-announced region is hiddenSpinnerProps.decorative
Never a skeleton for an unknown-length list, a variable-height card, or content whose shape depends on the responsejudgement

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.

ElapsedShow
under 100msnothing. The result is the feedback. A spinner here reads as a stutter
under 1sstill nothing. Thought is unbroken; a spinner that appears and vanishes is noise
1–10san indeterminate busy indicator. Keep the trigger visible and disabled, not replaced
over 10sdeterminate 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.

RuleRefused by
Never optimistic on money, a balance, stock, a quota, or a permission grantjudgement
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 oncejudgement. likedByMe ? {} : { likeCount: n + 1 } is the shape; likeCount + 1 is the bug
Rollback is written, never assumedthe 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

RuleRefused 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 renderedField owns the ids and hands them to the control through FieldControl
Never block pasteInputProps 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 answerjudgement — 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 wrongjudgement
The error text is announced with the control it belongs toField 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 suggestionsone 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 ratedeltaMood — 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 chartChartPoint.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

RuleRefused by
Animate transform and opacity. Nothing elseguards/animated-layout-property.ts
Never transition: allsame guard
A transition naming no property is all — name the propertiessame guard, which reports the omission as the implicit all it is
No layout property inside @keyframessame guard
Reach for t.duration() / t.easing(), or @include t.transition(<props>), never a literal 220ms or a raw cubic-bezierjudgement — the scales are in packages/ui/src/tokens/_motion.scss and nothing refuses a literal
Reduced motion is honoured globally, not per componenttokens/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

RuleRefused by
A click is answered by a control, never by a div with a handlerguards/semantic-interactive.ts — onClick/onMouseDown/onKeyDown on an inert tag
Never role="button", role="link" or role="checkbox" where the native element existssame 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 oversame guard
The focus ring is replaced, never only removedguards/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 formthe 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 defectthe guard never reports it
An icon-only control has a nameIconButtonProps.label is required — there is no unlabelled icon button
An icon with no label is decorative and hiddenIcon emits aria-hidden="true" unless given one
A live region exists before the message doesAppShell 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 notificationjudgement — two paths make a screen reader read it twice
Text meets 4.5:1 and the focus ring 3:1, in both themespackages/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

RuleRefused by
Confirmations only. Never the only copy of informationjudgement
Never the only affordance for an actionjudgement, plus the shape below
At most one control, and it is undo-shapedToastAction 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 sitethe ToastDwell union — 'short' | 'long' | 'sticky'; a call site cannot pass 6500
A sticky toast still carries a dismiss controljudgement
Never take focusToastRegion 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 booleanToastHold
At most three on screen; the rest queue and start their dwell when they surfaceTOAST_MAX_VISIBLE
The list announces the arrival, not the whole listToastRegion 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

RuleRefused by
Every image declares its box: width and height, or an aspect-ratioguards/image-dimensions.ts
One dimension alone is a refusal, not a half-measureboxFor throws X_UI_INVALID_VALUE — one alone reserves no aspect ratio
The LCP image is never lazyguards/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 deliberatelyImageProps.alt has no default
At most one priority image per routejudgement — nothing counts them
<Image> fabricates nothing: no rendition it was not given, no dimension it did not measurethe 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

RuleRefused by
Every *.island.tsx has a sibling *.island.states.tsguards/island-without-states.ts
A states file is pure data — no sibling import, no framework importassertIslandStatesPure, 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 readguards/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 questionguards/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 clickingjudgement — 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.

RuleRefused by
Which list: records with columns DataTable; thousands of one-shape rows VirtualList; nesting Tree; what happened Timeline; facts about one thing DescriptionListjudgement — 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 screenVirtualList'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 focusui-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 stopthe 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 openerDatePicker/DateRangePicker (pickers.test.ts, ui-date-pickers.e2e.test.ts)
A window takes an explicit IANA zoneTimeRangePickerProps.timeZone is required; a non-IANA zone is X_TIMEZONE_INVALID
Window presets are the app's, never the framework'stimeRangeUrl ships no list; a preset with neither seconds nor from/to is X_UI_INVALID_VALUE

Reordering and viewers

As of 2026-11.

RuleRefused by
A drag is Pointer Events with capture from a handle; never HTML5 drag eventsdrag/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 announcedsort-controller.test.ts, drag-interaction.test.ts
Reordering works by keyboard alone and with scripting offSpace/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 forkjudgement — the one seam the components take
Text a model or member wrote renders as a tree, never HTML; only http/https/mailto/relative linksviewers-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 boxviewers.test.ts
An island passes only the languages it showsui-barrel-bytes.test.ts: Markdown alone retains no languages/*
A diff reads with colour off: +/- gutters and a spoken word per changed rowviewers.test.ts

Stylesheets

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

RuleRefused byThe fix: it names
A length is a token or rem(). 1px hairlines and 0 are not lengths anyone scalesguards/raw-length.ts — a px in a declaration, an @include argument or an inline stylethe 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 ladderguards/raw-breakpoint.ts — @media with a width feature@include tokens.respond-to(md) / respond-down(md) / respond-between(md, lg)
A layer is a nameguards/raw-z-index.ts — a number in z-indexz-index: tokens.z(dropdown); behind its own stacking context, calc(-1 * #{tokens.z(raised)})
Elevation comes off the themed shadow scaleguards/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 tempoguards/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 compilesguards/undefined-style-class.ts — styles.x / styles['x-y'] against the COMPILED .module.scssthe nearest class the sheet declares, or the rule to add
A custom property a sheet reads is one the app declaresguards/undeclared-custom-property.ts — var(--x) in the compiled CSS with no declaration anywhere and no fallbackthe steps the scale really has: tokens.space(7) → 0 | 1 | 2 | …
Not a findingWhy
$gutter: 12px, a map entry, a parameter defaulta 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), @containernot 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 namesit declares its own answer; an inline style sets it
a sheet that does not compilethe 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.

RuleRefused byThe fix: it names
A repo.ts reads through the typed handleguards/repo-raw-sql.ts — a sql literal the handle can express: select *, insert, update or delete on one table, plain comparisonsthe handle call: db.posts.where({ orgId }).orderBy('createdAt', 'desc').limit(limit).all()
Not a findingWhy
a join, an aggregate, a CTE, a window, an or, a function callthe handle has no word for it; raw SQL in the repo is the answer
a statement composed from another sql fragmentcomposition, not one statement
sql in a query's source, a migration, a comment or a stringonly 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.

FlagWhy
<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 backdeletes the only thing telling a keyboard user where they are
transition: allanimates properties nobody chose, including ones not yet written
transition: 220ms ease with no property namedan omitted property is all
<img> with no width/height and no aspect-ratioevery element under it jumps when the bytes land
loading="lazy" on the hero imagea deliberate delay on the one paint the metric measures
"No results" rendered from a pending statethe four-state bug; empty must be unreachable before a result arrives
a skeleton whose box differs from the loaded contenta layout shift with extra steps
a spinner on a sub-100ms actionreads as a stutter
a progress bar with no cancel past 10sattention is already gone
an optimistic write to a balance, a stock count or a permissionthe rollback is visible and it is the wrong number that is remembered
onPaste blocked on a password fieldit makes password managers unusable and buys nothing
type="number" for a quantity or an amountlocale decimal separators do not survive it
a 14px input on a touch surfaceiOS zooms the viewport on focus and does not zoom back
a toast holding the only "Undo" for a destructive actionit expires on a timer
a toast that moves focusit interrupts typing to report something that was not asked for
a live region created in the same frame as its first messageit is not announced
an icon-only button with no labelsilence to a screen reader
a *.island.tsx with no states filenothing has ever seen it fail
a literal cubic-bezier(…) or 220ms in a component stylesheetthe scale exists so the curve can be retuned in one file
a raw hex in a component stylesheetit does not flip with the theme
a Previous button on a keyset lista 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 dolistUrl + a link-mode DataTable sort and page with scripting off

Where each mechanism lives

MechanismFile
the four-state unionpackages/ui/src/components/async-branch.ts
the motion scalespackages/ui/src/tokens/_motion.scss
the space, stroke, breakpoint, z and shadow scalespackages/ui/src/tokens/_space.scss, _stroke.scss, _breakpoints.scss, _z.scss, _shadow.scss
the global reduced-motion guard, and the default focus ringpackages/ui/src/tokens/reset.scss
focus-ring, control, transition, visually-hiddenpackages/ui/src/tokens/_mixins.scss
contrast, as a failing testpackages/ui/src/tokens/contrast.test.ts
live regions and the focus trappackages/ui/src/a11y.ts
the toast queue's rulespackages/ui/src/toast/toast-state.ts
a list's URL state (sort, cursor, limit, q)packages/ui/src/list-url.ts
your app's guardsguards/ 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.

Edit this page on GitHubThis page as markdown