Interface Rules - developerz-ai/ultimate GitHub Wiki
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.
| 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.
| 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.
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.
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.
| 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
|
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.
| 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.
| 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.
| 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.
| 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.
| 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 |
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.
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 |
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.
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 |
| 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 |
| 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.