# Frontend quality standards (2026)

The measurable bar UI work is held to. Project conventions win where they're stricter;
these are the floor, not the ceiling.

## Performance: Core Web Vitals (field/p75 targets)
- **LCP** (Largest Contentful Paint) ≤ **2.5 s**
- **INP** (Interaction to Next Paint: the current responsiveness metric, replaced FID)
  ≤ **200 ms**
- **CLS** (Cumulative Layout Shift) ≤ **0.1**
- Keep an interactive page's shipped JS lean (a budget around **≤400 KB gzipped** is a
  good default); lazy-load below-the-fold and heavy/optional code; minimize hydration;
  size and reserve space for images/media to avoid layout shift.

## Accessibility: WCAG 2.2 AA
- **Semantic HTML first:** real `<button>`/`<nav>`/`<label>`/headings; ARIA only to fill
  gaps semantics can't.
- **Keyboard**: every interactive element operable by keyboard; logical tab order; **focus
  visible** with ≥ **3:1** contrast against its background (never remove the outline
  without an equal replacement).
- **Contrast**: text ≥ **4.5:1** (≥ 3:1 for large text and UI/graphics).
- **Target size**: ≥ **24×24** CSS px (WCAG 2.2 AA, SC 2.5.8, including its spacing exception) is the conformance floor; **44×44** for primary touch targets is a DS preference, so a spaced 28 px secondary icon button is not an a11y defect.
- **No drag-only** interactions: provide a single-pointer alternative (SC 2.5.7).
- Labels/names on all controls; errors identified in text (SC 3.3.1) and status messages announced without moving focus (SC 4.1.3); respects `prefers-reduced-motion` (a platform requirement: SC 2.3.3 is AAA; § Motion below).
- **Also observe:** a text-spacing override loses no content (SC 1.4.12); hover/focus content is dismissible, hoverable and persistent (SC 1.4.13); a focused element is never fully hidden by sticky or fixed UI (SC 2.4.11).
- An A11Y finding names the SC, the observed failure and its evidence kind (`static`, `automated`, `manual`, `AT`); without an SC it is a heuristic lead, not a conformance failure. **Failing case:** "improve form accessibility" filed Important with no SC, or a sticky cookie bar hides the focused link and review passes.
- **Test** with keyboard, a screen reader, and an automated checker (e.g. axe): early.

## Motion
- Purposeful only (class table below). Never animate to mask slow loading; honor
  `prefers-reduced-motion`.

## Responsive
- Fluid layouts; no fixed widths that break. Verify at **320 / 768 / 1024 / 1440** px;
  preserve content and controls at **200% text zoom** and **400% browser zoom from
  1280 px** (320 CSS px). This is the canonical viewport set for UI evidence: [`ui.md`](../../rite-polish/reference/ui.md), [`browser-proof-checklist.md`](../../devrites-lib/reference/standards/browser-proof-checklist.md), and
  [`browser-polish-evidence.md`](../../rite-polish/reference/browser-polish-evidence.md) cite it rather than restating it.
- **No page-level horizontal scroll anywhere in 320–1920.** Sweep between checkpoints:
  a `1fr` track given a 1024 px image can overflow at 900 px. Fix intrinsic sizing
  (for example `minmax(0, 1fr)`), wrapping and media constraints; root clipping is
  not a reflow fix. Clip decoration only, never text, controls or focus indicators.
  A necessary two-dimensional table/map may scroll inside a named, bounded,
  keyboard-accessible region; surrounding headings, text and pagination must reflow.
  Record and test that exception. **Failing case:** a localized CTA disappears behind
  `overflow-x: clip`; the missing scrollbar does not prove accessible reflow.
- **`dvh`, not `vh`**, for full-height surfaces (`min-height: 100dvh`, never `h-screen` /
  `100vh`: mobile address bars break them). A hero fits the *initial* viewport: headline
  ≤2 lines, primary CTA above the fold.

## Design system
- Use the project's **tokens** (spacing/type/color roles): never hard-code a value a
  token covers. Compose from existing components; one icon set.

### Consistency locks (one per surface)
Lock these in `design-brief.md` and hold them page-wide: mid-scroll drift ("a different
website by section 7") is a tell: **one declared color strategy and role budget**
(§ Color commitment; no unbriefed accent appearing later), **one radius scale**, **one theme** (light or dark, not both).

## Numerical bar (enforceable specifics)

These are the hard numbers that move "good UI" from opinion to checkable. Project tokens win when stricter. Contrast, target size, focus and reflow numbers are A11Y floors; the rest are DS build defaults. A review finding against a DS default needs the mismatch evidence of the [admission rule](../../rite-polish/reference/anti-ai-slop.md).

### Color
- **OKLCH-only** for new tokens: perceptually uniform, predictable lightness
  steps across hues. Stop reaching for HSL; the lightness lies.
  - Example: `oklch(0.62 0.18 264)` for a saturated primary.
- **Never `#000` / `#fff`** as raw values. Pure black/white are too harsh and
  too clinical; use a near-black (`oklch(0.18 0 0)`) and near-white
  (`oklch(0.98 0 0)`) or the project's surface tokens.
- Contrast follows WCAG 2.2: text ≥ 4.5:1, UI/graphics ≥ 3:1. Verify
  against the *rendered* background, not the theoretical one. Procedure: pair every
  `color` declaration with its computed background; a button whose text and fill sit
  within 5% lightness and 0.05 chroma of each other fails regardless of the palette.
- **Restrained accent, capped:** one accent covers **≤5% of the viewport** at rest
  (3–5 placements above the fold). Accent-filled panels fail this strategy; another strategy requires the explicit brief and role budgets below.
- **Tint neutrals toward the brand hue:** pure-grey neutrals look sterile.
  Nudge every grey 0.005-0.01 chroma toward the brand colour: invisible at
  a glance, but the surface stops feeling generic.
- **Alpha is a smell:** heavy translucency usually means an incomplete palette.
  Define explicit overlay/hover tokens; transparency is for focus rings and scrims.
- **Every colored surface token ships its `on-` pair** (`primary`/`on-primary`,
  `destructive`/`on-destructive`, …) so contrast is designed in, not patched after.

#### Color commitment: pick the strategy before the palette
Decide *how committed* the surface is to colour before opening the picker. Four positions on a single axis; pick one, then design within it:

| Strategy | Rough coverage | Use for |
|---|---|---|
| **Restrained** | tinted neutrals + 1 accent ≤5% of the viewport at rest | Most product UI; brand surfaces that want to look quiet. |
| **Committed** | one saturated colour carries 30-60% of the surface | Brand pages with a strong identity; product feature surfaces that need a hero colour. |
| **Multi-role** | 3-4 named colour roles, each used deliberately | Brand campaigns; product data-viz with distinct meanings. |
| **Saturated** | the surface *is* the colour: full-bleed colour ground | Brand heroes, campaign pages, splash moments. |

Two corollaries:
- The ≤5% accent cap belongs to **Restrained only**. For Committed / Multi-role /
  Saturated, the brief must name the strategy, purpose, tokens and measured coverage
  budget per role and viewport. No blanket waiver: an unbudgeted colored panel fails.
  Preserve distinct success/warning/error meanings; semantic states do not authorize decorative accents. Check rendered captures against the declared budgets.
- *Register* (brand vs product) doesn't pick the strategy by itself: brand
  surfaces aren't always Saturated, product surfaces aren't always Restrained. Pick from the scene, not the category.

### Calibration: density & motion
Colour commitment fixes *how much colour*; two more axes fix *how much space* and
*how much movement*. Set one position on each (from the **scene sentence**, the same
way colour is picked) and carry both in `design-brief.md` so the build targets a
calibration instead of re-deciding it per slice.

**Density**: information per viewport; drives which spacing steps dominate:

| Position | Feels like | Spacing steps that dominate | Use for |
|---|---|---|---|
| **Airy** | gallery / calm / room to breathe | `32 / 48 / 64 / 80 / 96` | brand pages, onboarding, focus moments, low-data surfaces |
| **Balanced** | most product UI | `16 / 20 / 24 / 32` | dashboards, forms, settings: the default |
| **Dense** | cockpit / data-rich / power tool | `4 / 8 / 12 / 16` | tables, monitors, terminals, pro tools lived in all day |

**Motion**: how much the surface moves; drives which motion classes (table below) are in play:

| Position | Feels like | Motion classes in play | Use for |
|---|---|---|---|
| **Minimal** | crisp, almost still | Instant + State | dense tools, regulated/trust surfaces, reduced-motion-leaning |
| **Standard** | responsive, purposeful | Instant + State + Layout | most product + brand UI: the default |
| **Expressive** | choreographed, scroll-aware | + Entrance, deliberate sequencing | brand heroes, launch/campaign moments, story scroll |

Corollaries (same shape as colour commitment):
- Pick from the **scene sentence**, not the category: "an on-call SRE at 2am" → Dense +
  Minimal; "a launch hero in afternoon light" → Airy + Expressive. Register doesn't decide
  it by itself.
- `prefers-reduced-motion` overrides Motion **downward at runtime** regardless of position:
  an Expressive surface still ships a Minimal path.
- The position is a target, not a straitjacket: a surface may break density locally for
  emphasis: name the exception in the brief, don't let the whole page drift off it.

### Spacing
- **4pt base scale** (`4 / 8 / 12 / 16 / 20 / 24 / 32 / 40 / 48 / 64 / 80 /
  96`). Project tokens may use a multiplier; never hardcode in-between
  values.
- Inline spacing rhythms in multiples of 4 px. Optical alignments may need a
  1 - 2 px nudge: record the *why* in a comment.

### Typography scale
- **Brand surfaces**: fluid `clamp()` headings (e.g.,
  `clamp(2rem, 1rem + 3vw, 3.5rem)`). Display/body pairing OK.
- **Product surfaces**: **fixed rem scale** (1.125 - 1.2 ratio). One family
  usually carries headings, body, labels, data.
- Body line-height ~1.5; headings ~1.1 - 1.25. Prose width ~ 65-75 ch.
- Adjacent hierarchy levels differ by **≥1.5× in at least one signal** (size, weight,
  or color); verified under a blur(8px) squint — a flat field means the hierarchy is
  decorative. **Failing case:** label and helper text differ only by 0.05rem.
- All-caps display text sets **line-height ≥1.0**; tighter collides cap heights.
- **Hero font-scale attribution:** a hero headline that wraps past its planned lines at
  the target viewport is a font-size error first — fix the scale, don't shorten the
  copy. **Failing case:** a four-line hero headline "fixed" by cutting the sentence.
- Display ceiling: `clamp()` max ≤ ~6rem: larger is shouting. Display
  letter-spacing floor **≥ −0.04em**; tighter and the letters touch.
- `text-wrap: balance` on h1-h3; `text-wrap: pretty` on long prose.

### Motion
| Class | Duration | Use |
|---|---|---|
| Instant | 100 - 150 ms | Hover/press, tooltip enter, color shifts |
| State | 200 - 300 ms | Toggle, focus ring, dropdown |
| Layout | 300 - 500 ms | Modal/drawer enter, route transitions |
| Entrance | 500 - 800 ms | Page entrance, hero animation |

- **Exit at ~75 % of enter.** A 300 ms enter pairs with a 225 ms exit.
- **Bounce / elastic easing banned** unless the project's design system
  explicitly uses it.
- Honor `prefers-reduced-motion`: reduce or remove non-essential motion entirely. Reduced means fewer and gentler, never zero feedback: under emulated `reduce`, content reaches its final visible state and loading, progress and focus stay perceivable. Escalate a missing reduced path to Important only for vestibular-scale motion (parallax, zoom, large translation).
- **Regardless of preference:** auto-moving or auto-updating content lasting over 5 s can be paused, stopped or hidden (SC 2.2.2); nothing flashes more than 3 times per second (SC 2.3.1); motion never gates input (focus moves and the first key or click lands during an enter animation); re-triggered motion retargets from its current state instead of restarting; zoom is never disabled (`user-scalable=no`, `maximum-scale=1`; SC 1.4.4). **Failing case:** an `opacity: 0` entrance with no reduced branch stays invisible under `reduce`, or a 7 s autoplay banner without pause passes because no preference was set.
- **Loading feedback appears by ~200 ms**: skeletons/spinners replace stillness
  after that threshold; below it, feedback flicker reads as jank. Hover intents
  may delay 150–300 ms; focus indication never fades in — the ring is instant.

### Forms
- Inputs match button height (DS: 44 px for primary touch); a visual target under 24×24 CSS px expands its hit area (pseudo-element) to 24 px or meets SC 2.5.8's spacing exception (§ Accessibility).
- Helper/error text reserves **`min-height: 1lh`** so messages never shift layout.
- Border width is pinned across states (state changes recolor, never resize).
- Disabled is visible through **three channels**: muted color, `not-allowed`
  cursor, and blocked interaction — color alone fails.
- Mobile inputs set **≥16 px** font (smaller triggers iOS zoom-on-focus).
- Numeric/data columns set **`font-variant-numeric: tabular-nums`**; columns of
  figures align or the surface reads broken.

### Dark mode (three-axis compensation)
A token-flip dark mode is sterile. When the project ships dark, compensate
three axes from light:
- **Line-height** `+0.05 – 0.10` (text breathes more on dark surfaces).
- **Letter-spacing** `+0.01 – 0.02 em` (perceived spacing tightens on dark).
- **Weight** `+1 step` for very small or low-contrast text.

If the project has dark tokens already, follow them. If not and dark is in
scope, propose the compensation rather than ship a flat invert.

### Focus & states (role lattice)
Every state a role makes applicable is required; applicability comes from the role, not a fixed count.
- **Control states:** `default`, `active`, `focus-visible`; `hover` for hover-capable pointers; `disabled` where the control can be disabled; `loading` for async actions; `selected` for selectable controls; `invalid` wherever input can be invalid.
- **Surface states** (a view that loads, lists or edits data): loading (initial vs subsequent), empty (first-run with a next action vs filtered with a way back), error with recovery, **partial** (a missing value renders the project placeholder with an accessible "not available" name, never `null`, `0` or blank), **stale/offline** (banner with retry, not a silently cached render), permission-denied (401 asks to sign in; 403 explains access and never renders as empty), pending/submitting (no double submit), success, and **conflict** (concurrent edit / version mismatch).
- Mark each state per surface `present`, `partial`, `missing`, `not-needed (<reason>)` or `unknown` (not observed); never a bare count. `not-needed` without a reason is `missing`. **Failing cases:** a row with a missing value renders `0` or blank and the review reads it as real data; a 403 list shows "No invoices yet" and review says states covered; a static nav link is charged a missing `loading` state.
- `:focus-visible` ring: **2 - 3 px**, **≥ 3:1** contrast against the
  background, **offset 2 px** so the focus is unambiguous on dense layouts.

### Browser chrome
UA chrome is AES unless the DS defines it. Focus is judged by SC 2.4.7 and 1.4.11 on the actual background and by the DS focus-ring token where one is declared: a UA ring that passes both is not a finding. `::selection`, `caret-color` and scrollbars follow the DS when it defines them; task surfaces get no custom scrollbar the DS lacks. **Failing case:** a settings page with compliant UA focus rings is filed "unstyled UA chrome" Important.

### Container queries vs viewport queries
- **Component breakpoints:** use **container queries** (`@container`). The
  same card adapts to its column whether the viewport is 320 or 1920.
- **Page-level layout:** use viewport queries
  (`@media (min-width: ...)`) for sidebar collapses, header reflows.
- Mixing the two is normal; using only one is usually a code smell.

### Semantic z-index scale
Pick a scale per surface role; never use raw `z-index: 9999`. Suggested
floor:

| Role | z |
|---|---|
| Base content | `auto` / `0` |
| Sticky header | `50` |
| Dropdown / popover | `100` |
| Drawer / sheet | `200` |
| Modal | `300` |
| Toast | `400` |
| System dialog / debug overlay | `500` |

### Materiality (elevation & surface)
Depth is earned, not defaulted. Reach **down** this ladder in order; stop at the first
rung that carries the structure.
- **Hairline first.** A **1 px border / divider** (a hairline in a near-neutral token)
  defines structure before any shadow does. Borders separate; shadows lift. Most "cards"
  want a hairline, not elevation.
- **Elevation is a token scale, not an ad-hoc value.** When a surface genuinely lifts
  (menu, popover, dragged item), use a **small shadow set from the design system** with one
  consistent light source: never a one-off `box-shadow`. The elevation step and the
  semantic z-index role move together.
- **Texture needs contrast.** Grain / noise / pattern earns its place only when it reads
  against the surface. Invisible texture is bytes with no signal: drop it.
- **Asset-led material.** Rich material (photographic grain, real product imagery,
  generated art) comes from a **real raster / generated asset**, not an SVG/CSS
  approximation of one. Don't fake a photo with gradients; ship the asset or use a flat
  token.
- **No decorative glass.** `backdrop-filter: blur(...)` is for a fixed/sticky surface over
  moving content (a sticky header, a sheet over scroll), never a default panel look
  ([`anti-ai-slop.md`](../../rite-polish/reference/anti-ai-slop.md)).

### NEVER (UI numerical bar)
- Never reintroduce anything [`rite-polish` anti-ai-slop](../../rite-polish/reference/anti-ai-slop.md) bans (review findings follow its admission rule).
- Never hard-code a spacing value the 4 pt scale or project tokens cover.
- Never animate an exit at 100 % of enter duration (feels uncontrolled).
- Never use raw `z-index` numbers outside the semantic scale.
- Never use viewport queries for component-internal reflows when the
  component is reused at different widths.
- Never ship dark mode as a flat token invert (compensate three axes).
- Never reach for a shadow where a 1 px hairline carries the structure.
- Never invent a one-off `box-shadow` outside the elevation token set.
- Never ship texture / grain that doesn't read against its surface.

## Verification gate (a UI slice isn't done until all pass)
- [ ] Renders with **no console errors/warnings**
- [ ] **Keyboard**: tab through reaches everything; focus visible; Esc/Enter behave
- [ ] **Screen reader** conveys content + structure
- [ ] **All states**: every applicable control and surface state of the role lattice (§ Focus & states) marked, none `missing`, `unknown` recorded `cannot_verify`; not a shorter local list
- [ ] **Responsive** at the canonical viewport set (§ Responsive); no lost content or
      controls, no page overflow; bounded exceptions tested; zoom-safe
- [ ] **No accessibility violations** (axe or equivalent, WCAG 2.2 tags enabled for a 2.2 AA claim); every `incomplete` result resolved as a manual row, `inapplicable` read as not exercised: zero violations alone never ticks this
- [ ] Meets the **CWV budget** above (measure, don't assume; lab vs field status per [`performance.md`](../../devrites-lib/reference/standards/performance.md) § Frontend)
- [ ] Aligned to the **design system** (tokens, components, type, spacing)
- [ ] **Focus indicator** passes SC 2.4.7/1.4.11 (DS focus token where declared); no custom scrollbar the DS lacks (§ Browser chrome)
- [ ] Clickables show `cursor: pointer` + a hover state; icons are SVG from the one set
      (no emoji); nothing trapped under fixed/sticky bars
- [ ] **Consistency locks hold** (declared color strategy/roles / one radius scale / one theme) and the
      mechanical pre-flight's ENG/DS rows pass ([`anti-ai-slop.md`](../../rite-polish/reference/anti-ai-slop.md))

## Craft convergence bar

Craft work terminates; it does not loop. Findings classify as **Critical /
Important / Suggestion** on the pack's severity scale, owned by
[`code-review.md`](../../devrites-lib/reference/standards/code-review.md): done
means zero Critical, zero Important, and every remaining Suggestion accepted **in
writing** (owner + reason) in `polish-report.md` — an unwritten suggestion is an
ignored finding, not an accepted one. AES proposals are capped at Suggestion and
never block convergence. Re-running the full bar after a
fix pass happens only on an explicit opt-in ("re-run the full bar"); "keep going"
is not opt-in. Each pass captures every supported viewport, not only the one that
failed. **Failing case:** the 375 px overflow is fixed, 320 px still overflows,
and the report reads clean.
