.github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
validate:
name: Validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Validate (no-dashes + lint)
run: npm run validate
LICENSE
MIT License
Copyright (c) 2026 skill-frontend-excellence contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
.github/workflows/release.yml
name: Release
# Opens a release PR that bumps package.json (and package-lock.json
# if present) to the next semver. The PR goes through whatever
# branch-protection rules `main` has configured (review, code
# scanning, CODEOWNERS, thread resolution) exactly like any other
# change; the bot does not push directly to main. Once the PR
# merges, a separate `tag-on-main.yml` workflow creates the
# matching `v<version>` tag.
#
# Publishing does NOT auto-chain: tags pushed with the default
# `GITHUB_TOKEN` do not trigger `on: push: tags` workflows (GitHub
# design, to prevent workflow recursion). After the tag lands,
# dispatch `publish.yml` against the tag via Actions UI (documented
# in README `## Releasing`). To remove that manual step, swap the
# tag-push credential in `tag-on-main.yml` for a GitHub App token
# or fine-grained PAT.
on:
workflow_dispatch:
inputs:
bump:
description: "Version bump type"
required: true
type: choice
options:
- patch
- minor
- major
permissions:
contents: write
pull-requests: write
# Serialize release dispatches so two operators can't race on the
# same `release/v<version>` branch. A queued run picks up after
# the earlier one finishes.
concurrency:
group: release
cancel-in-progress: false
jobs:
open-release-pr:
name: Open release PR
runs-on: ubuntu-latest
steps:
- name: Refuse dispatch from non-main refs
# `workflow_dispatch` can be fired from the UI against any
# ref. We want the run to show as FAILED with a clear
# error when an operator accidentally picks a feature
# branch, not skipped/green the way a job-level `if` would
# render. Releases always cut from main.
if: github.ref != 'refs/heads/main'
run: |
echo "::error::Release workflow must be dispatched from main; got ${{ github.ref }}."
echo "::error::Switch the branch selector in the Actions UI to \`main\` and retry."
exit 1
- uses: actions/checkout@v4
with:
# Defensive double-belt: even if the dispatch came from
# a non-main ref and the early step above is somehow
# bypassed, explicitly check out main so the bump commit
# is always parented to it.
ref: main
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
# No `cache: npm` here: this job only runs `npm version
# --no-git-tag-version --ignore-scripts`, which touches
# package.json/package-lock.json but never installs
# anything. Caching would add setup overhead with no
# install step to benefit from it.
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Bump version on a release branch
id: bump
run: |
# Read current to detect no-op bumps (shouldn't happen with
# npm version <bump>, but bail loudly if it does).
BEFORE=$(node -p "require('./package.json').version")
# --ignore-scripts: npm version runs preversion/version/
# postversion lifecycle scripts by default. None are defined
# today, but a future addition (or a transitive one via a
# config change) would silently run in CI with write access
# to the repo. Keep the bump bounded to version files only.
npm version ${{ inputs.bump }} --no-git-tag-version --ignore-scripts
AFTER=$(node -p "require('./package.json').version")
if [ "$BEFORE" = "$AFTER" ]; then
echo "::error::npm version produced no change ($BEFORE == $AFTER)"
exit 1
fi
BRANCH="release/v${AFTER}"
echo "version=${AFTER}" >> "$GITHUB_OUTPUT"
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
# Use -B so a stale branch from a prior failed attempt is
# reset in place rather than making us error out.
git checkout -B "${BRANCH}"
git add package.json
if [ -f package-lock.json ]; then
git add package-lock.json
fi
git commit -m "release: v${AFTER}"
# Push safely whether or not the branch pre-exists on the
# remote. actions/checkout does NOT seed remote-tracking
# refs for arbitrary release branches, so bare
# `--force-with-lease` has no base and can degrade to
# unsafe behaviour. We fetch the remote branch first (if
# it exists) and then force-with-lease using the EXPLICIT
# ref:sha form so the lease is actually enforced against
# a known SHA. If the branch doesn't exist on the remote
# yet, a plain push is sufficient and safe.
if git ls-remote --exit-code --heads origin "${BRANCH}" >/dev/null 2>&1; then
git fetch origin "+refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}"
REMOTE_SHA=$(git rev-parse "refs/remotes/origin/${BRANCH}")
git push --force-with-lease="refs/heads/${BRANCH}:${REMOTE_SHA}" origin "${BRANCH}"
else
git push origin "${BRANCH}"
fi
- name: Open or update the release PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ steps.bump.outputs.version }}"
BRANCH="${{ steps.bump.outputs.branch }}"
# Assemble the PR body with printf so every paragraph sits
# at column 0 in the final string. A previous shape used a
# single "..." string split across lines, which preserved
# the yaml-script indentation on every continuation line
# and made GitHub render the PR body as a <pre> code block.
BODY="$(printf '%s\n\n%s\n\n%s' \
"Automated release PR created by \`release.yml\`. Bumps package.json (and package-lock.json, if present) to \`v${VERSION}\`." \
"Once this PR merges to main, \`tag-on-main.yml\` creates the matching \`v${VERSION}\` tag. After the tag lands, dispatch \`publish.yml\` against \`v${VERSION}\` via Actions to publish the package to npm (see README \`## Releasing\` for why this step is manual)." \
"Review the diff for any surprises (package-lock.json regeneration drift, etc.) before approving.")"
# `gh pr view "$BRANCH"` matches closed/merged PRs too, so
# a previous release attempt that left a closed PR on the
# same head branch would cause this step to silently edit
# the closed PR and never open a new one; the operator
# would see the workflow succeed but have no PR to merge.
# Filter explicitly to open PRs for this head branch.
OPEN_PR_NUMBER="$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty' 2>/dev/null || true)"
if [ -n "$OPEN_PR_NUMBER" ]; then
gh pr edit "$OPEN_PR_NUMBER" --title "release: v${VERSION}" --body "$BODY"
echo "Updated existing open release PR #$OPEN_PR_NUMBER for $BRANCH"
else
gh pr create \
--base main \
--head "$BRANCH" \
--title "release: v${VERSION}" \
--body "$BODY"
fi
SKILL.md
---
name: frontend-excellence
description: Holistic, framework-agnostic playbook for shipping ultra-fast, accessible, search-friendly, visually distinctive web interfaces. Combines Lighthouse mastery (target 95+ mobile, 99+ desktop, 100 A11y/BP/SEO), UI/UX patterns drawn from Apple HIG and Material Design, design aesthetics that avoid generic AI styling, plus deep guidance on performance, accessibility, SEO, motion, forms, layout, and data visualization. Apply this when building, reviewing, refactoring, or polishing any web UI regardless of framework (vanilla HTML/CSS/JS, React, Vue, Svelte, Astro, Solid, Next.js, Nuxt, SvelteKit, Remix, Qwik, Lit, Web Components). Use to make decisions about typography, color, spacing, motion, contrast, semantic structure, asset loading, render strategy, hydration, third-party scripts, image formats, font loading, JS budgets, CSS architecture, ARIA, keyboard flows, focus management, structured data, meta tags, and Core Web Vitals.
---
# Frontend Excellence
A single, holistic skill for delivering web interfaces that look unforgettable, perform near the top of the Lighthouse curve, and degrade gracefully across devices and assistive technologies. Framework-agnostic by design: every rule is expressed as a principle plus a concrete check, never as a library API.
## When to Use
Apply this skill any time the work changes how an interface **looks, feels, moves, loads, ranks, or is interacted with**. Examples:
- Building or refactoring any page, form, modal, navigation, table, chart, or component
- Choosing typography, color, spacing, motion, or visual style
- Auditing or improving Core Web Vitals (LCP, INP, CLS), bundle size, or hydration cost
- Auditing or improving accessibility (contrast, keyboard, screen reader, focus, dynamic type)
- Auditing or improving on-page SEO (titles, descriptions, headings, structured data, canonicals)
- Reviewing visual polish, hierarchy, contrast, density, dark/light mode parity
- Replacing generic AI-flavored styling with a distinctive aesthetic direction
- Auditing or polishing an existing multi-page site against a reference design or production baseline
Skip this skill for backend logic, infrastructure, data pipelines, or non-visual scripting.
## Loading policy (read before pulling references)
Each file under `references/` averages 400 to 600 lines. Loading more than three at once burns context for content you do not need. Pick the 2 to 3 files that match the current task by reading the Routing tables below (By Task, By Symptom, then Reference Index). If a table does not resolve cleanly, read the YAML frontmatter at the top of candidate files (about 15 lines each, via `head -20`) before loading the rest. Drop a reference when the current task no longer matches its purpose.
## North Star Targets
Every interface should meet these bars before being considered "done". Treat any failure as a blocking defect, not a polish task.
| Category | Mobile target | Desktop target | Source |
|----------|--------------|----------------|--------|
| Lighthouse Performance | >= 95 | >= 99 | Lighthouse |
| Lighthouse Accessibility | 100 | 100 | Lighthouse + axe |
| Lighthouse Best Practices | 100 | 100 | Lighthouse |
| Lighthouse SEO | 100 | 100 | Lighthouse |
| Largest Contentful Paint (LCP) | < 2.5s | < 2.0s | Core Web Vitals |
| Interaction to Next Paint (INP) | < 200ms | < 200ms | Core Web Vitals |
| Cumulative Layout Shift (CLS) | < 0.1 | < 0.05 | Core Web Vitals |
| Time to First Byte (TTFB) | < 800ms | < 600ms | Core Web Vitals |
| First Contentful Paint (FCP) | < 1.8s | < 1.0s | Lighthouse |
| Total Blocking Time (TBT) | < 200ms | < 100ms | Lighthouse |
| Initial JS payload (gzipped) | < 90 KB | < 130 KB | Budget |
| Initial CSS payload (gzipped) | < 25 KB | < 35 KB | Budget |
| Web font payload (per screen) | <= 2 families, <= 4 weights | same | Budget |
These are the universal bar. A given project may consciously relax a budget for a specific surface (e.g., a heavily interactive client-rendered tool that legitimately ships more JS), but the relaxation should be a recorded, justified exception, not a default.
## Priority Stack (read top-down on any review)
1. **Crawlability and indexability** (does the page exist in the index, with the right canonical and meta?)
2. **Accessibility correctness** (semantic HTML, contrast, keyboard, focus, screen reader)
3. **Core Web Vitals** (LCP, INP, CLS, TTFB)
4. **Layout and responsive integrity** (no horizontal scroll, no clipped content, safe-area respect)
5. **Visual hierarchy and intentional aesthetic** (typography, color, spacing, motion, density)
6. **Interaction quality** (hover, press, focus, loading, error, empty, success states)
7. **Forms and feedback** (validation, error placement, recovery, autofill, autosave)
8. **Motion and animation** (purposeful, reduced-motion safe, transform-only)
9. **Data and charts** (legible, accessible, responsive)
10. **Polish and craft** (icon discipline, dark/light parity, brand fidelity)
Higher-priority items always block lower-priority items. Never trade contrast for aesthetics. Never trade INP for animation.
### When rules conflict (tie-breakers)
The Priority Stack covers the common case. Use this table when two specific rules push the other way:
| Conflict | Tie-breaker |
|---|---|
| Animation polish vs INP budget | INP wins. An animation that drives INP over 200ms is a defect, not a polish item. |
| Aesthetic preference vs WCAG contrast | Contrast wins. No 3:1 large-text or 4.5:1 body floor is negotiable for visual taste. |
| LCP preload vs initial JS budget | JS budget wins. Find a smaller LCP element or defer the preload; do not blow the budget. |
| SSR for SEO vs INP under hydration cost | Split: SSR the shell for indexability, defer islands to keep INP under 200ms. |
| Motion polish vs `prefers-reduced-motion` | Reduced motion wins. Motion is the enhancement, not the baseline. |
| Brand color vs dark-mode text contrast | Use a lightened accent for text on dark surfaces; brand stays for backgrounds. See `design.md`. |
## Decision: Render Strategy
A page lands in one of five render strategies. Choose by these axes; deep treatment in `performance.md` and `seo.md`.
| If ... | Pick |
|---|---|
| Indexable, mostly static content, low personalisation, traffic spike resilient | **SSG** (static site generation) |
| Indexable, per-request personalisation, dynamic data, server has compute budget | **SSR** (server-side rendering), optionally streamed |
| Index not required, deeply interactive, app-shell experience, INP-sensitive | **CSR** (client-side rendering), with route-level code splitting |
| Indexable, partial personalisation, want SSG for the shell with revalidation | **ISR** (incremental static regeneration) |
| Mostly static + a few interactive widgets, want minimal JS | **Static plus islands** (partial hydration / resumability) |
Decision rule of thumb: indexability first (SSG / SSR / ISR for indexable surfaces); interactivity cost second (split into islands if SSR pages run over the JS budget); fall back to CSR only when index is not required and the app shell is the product.
## Routing: by task
Pick the row that matches what you have been asked to do. Load the named files first; pull others only if those files point you elsewhere.
| You are asked to ... | Load these files |
|---|---|
| Build a new page from scratch | [components.md](references/components.md), [design.md](references/design.md), [responsive.md](references/responsive.md), [performance.md](references/performance.md) |
| Audit an existing site against a reference | [audit-workflow.md](references/audit-workflow.md), [defects.md](references/defects.md), [design.md](references/design.md) |
| Fix a failing Lighthouse audit | [lighthouse.md](references/lighthouse.md), [performance.md](references/performance.md), [debug-recipes.md](references/debug-recipes.md) |
| Polish visual quality on an existing page | [design.md](references/design.md), [ui-ux.md](references/ui-ux.md), [defects.md](references/defects.md) |
| Add a form or sign-in flow | [forms.md](references/forms.md), [auth.md](references/auth.md), [accessibility.md](references/accessibility.md) |
| Set up CI gates and pre-merge checks | [testing.md](references/testing.md), [lighthouse.md](references/lighthouse.md), [observability.md](references/observability.md) |
| Diagnose a production issue | [observability.md](references/observability.md), [debug-recipes.md](references/debug-recipes.md), [defects.md](references/defects.md) |
| Verify a page is done before shipping | [pre-launch.md](references/pre-launch.md), [quick-reference.md](references/quick-reference.md) |
| Internationalise or localise a surface | [i18n.md](references/i18n.md), [responsive.md](references/responsive.md), [accessibility.md](references/accessibility.md) |
| Harden security (CSP, headers, Trusted Types, SRI) | [security.md](references/security.md), [lighthouse.md](references/lighthouse.md), [pre-launch.md](references/pre-launch.md) |
| Add PWA, offline, or installability | [pwa-offline.md](references/pwa-offline.md), [performance.md](references/performance.md) |
| Embed third-party widgets or be embedded | [embed-patterns.md](references/embed-patterns.md), [security.md](references/security.md), [auth.md](references/auth.md) |
| Print stylesheet or transactional email HTML | [print-email.md](references/print-email.md) |
| Add charts, tables, or data display | [data-viz.md](references/data-viz.md), [ui-ux.md](references/ui-ux.md), [accessibility.md](references/accessibility.md) |
| Add or refine motion and animation | [motion.md](references/motion.md), [performance.md](references/performance.md) |
| Review a change against known anti-patterns | [anti-patterns.md](references/anti-patterns.md), [defects.md](references/defects.md) |
## Routing: by symptom
Use observable problem signals to jump straight to the relevant file and section.
| Symptom | Likely root, file and section to load |
|---|---|
| LCP > 2.5s, LCP regression | preload / fetchpriority / hydration cost: [performance.md: Core Web Vitals](references/performance.md#core-web-vitals) |
| INP > 200ms, slow interaction | long task / handler / hydration: [performance.md: Core Web Vitals](references/performance.md#core-web-vitals), [debug-recipes.md: INP regression](references/debug-recipes.md#inp-regression) |
| CLS > 0.1, layout jump | async element not reserved: [performance.md: Core Web Vitals](references/performance.md#core-web-vitals), [defects.md: Defect Lookup Table](references/defects.md#defect-lookup-table) |
| font swap CLS | missing size-adjust / metrics-override on the fallback: [debug-recipes.md: Font-swap CLS](references/debug-recipes.md#font-swap-cls), [performance.md: Font Strategy](references/performance.md#font-strategy) |
| Lighthouse SEO < 100 | noindex on a graded page, missing canonical: [lighthouse.md: Failing Audit to Fix Map](references/lighthouse.md#failing-audit-to-fix-map), [seo.md: Indexability](references/seo.md#indexability) |
| Lighthouse Best Practices < 100 | CSP violation, console errors, deprecation: [lighthouse.md: Failing Audit to Fix Map](references/lighthouse.md#failing-audit-to-fix-map), [security.md: CSP at Depth](references/security.md#csp-at-depth) |
| errors-in-console, stale SRI beacon | third-party beacon CORS / stale pinned hash, or a client-blocker false positive: [debug-recipes.md: Lighthouse flake triage](references/debug-recipes.md#lighthouse-flake-triage) |
| phantom dev failure (low score only on the dev server) | scoring the dev server, not a production build: [lighthouse.md: Run Lighthouse Properly](references/lighthouse.md#run-lighthouse-properly) |
| image too small on retina (image-size-responsive) | largest srcset candidate below CSS width times DPR: [responsive.md: Responsive Images](references/responsive.md#responsive-images), [lighthouse.md: Failing Audit to Fix Map](references/lighthouse.md#failing-audit-to-fix-map) |
| contrast fail, especially after a build change | low ratio, or a CSS-delivery change that reordered the cascade: [accessibility.md: Color and Contrast](references/accessibility.md#color-and-contrast), [lighthouse.md: Failing Audit to Fix Map](references/lighthouse.md#failing-audit-to-fix-map) |
| duplicate id | reused component with a hardcoded id: [components.md: Drift Detection](references/components.md#drift-detection) |
| scroll lock side shift | modal missing scrollbar-width compensation: [ui-ux.md: Modals and Overlays](references/ui-ux.md#modals-and-overlays), [defects.md: Defect Lookup Table](references/defects.md#defect-lookup-table) |
| hydration mismatch | server vs client divergence: [debug-recipes.md: Hydration mismatch](references/debug-recipes.md#hydration-mismatch), [performance.md: JavaScript Strategy](references/performance.md#javascript-strategy) |
| viewport overflow, horizontal scroll | bleed in a child element: [responsive.md: Horizontal Scroll: Forbidden](references/responsive.md#horizontal-scroll-forbidden), [defects.md: Defect Lookup Table](references/defects.md#defect-lookup-table) |
| focus trap leak, focus not visible | overlay focus management: [debug-recipes.md: Focus trap leak](references/debug-recipes.md#focus-trap-leak), [accessibility.md: Keyboard](references/accessibility.md#keyboard) |
| third-party script slow, analytics blocking | third-party-script discipline / async-defer matrix: [performance.md: Third-Party Strategy](references/performance.md#third-party-strategy), [build-hygiene.md: Dependency-Cost Discipline](references/build-hygiene.md#dependency-cost-discipline) |
| broken on Firefox / Safari only | feature-support assumption, missing fallback: [responsive.md: Media Preferences](references/responsive.md#media-preferences), [motion.md: Reduced Motion](references/motion.md#reduced-motion) |
| consent banner CLS, bounding box jumps | overlay not reserved: [ui-ux.md: Modals and Overlays](references/ui-ux.md#modals-and-overlays), [seo.md: Indexability](references/seo.md#indexability) |
| aria-hidden background still announced, inert leak | overlay applied to the wrong ancestor: [accessibility.md: Modal and Dialog](references/accessibility.md#modal-and-dialog), [ui-ux.md: Modals and Overlays](references/ui-ux.md#modals-and-overlays) |
| hero animation regresses LCP or TBT | above-the-fold motion not gated against a budget: [motion.md: Hero Animations](references/motion.md#hero-animations) |
## Reference Index
Detailed guidance lives in `references/`. Each row gives a one-line purpose plus the keywords that should pull the file.
| File | Use when (keywords) |
|------|---------|
| [lighthouse.md](references/lighthouse.md) | Lighthouse, audit, score, CI gate, csp-xss, duplicate-id, is-crawlable, score variance, user-flow |
| [performance.md](references/performance.md) | LCP, INP, CLS, TTFB, performance, hydration, bundle, BFCache, Speculation Rules, Early Hints, fetchpriority, render strategy, SSR, SSG, CSR, third-party script, list virtualization |
| [accessibility.md](references/accessibility.md) | a11y, WCAG, ARIA, screen reader, keyboard, focus, contrast, forced colors, reduced motion, dynamic type |
| [seo.md](references/seo.md) | SEO, canonical, sitemap, robots, structured data, JSON-LD, AEO, GEO, llms.txt, hreflang, Open Graph |
| [ui-ux.md](references/ui-ux.md) | UI, UX, modal, popover, dialog, drawer, menu, tooltip, breadcrumb, touch target, hit target, popover API, inert |
| [design.md](references/design.md) | design, typography, color, OKLCH, P3, dark mode, brand, variable font, @layer |
| [responsive.md](references/responsive.md) | responsive, breakpoint, container query, viewport, safe area, dvh, srcset, subgrid, scrollbar-gutter |
| [motion.md](references/motion.md) | motion, animation, transition, easing, View Transitions, scroll-driven, WAAPI, @starting-style |
| [forms.md](references/forms.md) | form, validation, input, autofill, autocomplete, constraintValidation |
| [data-viz.md](references/data-viz.md) | chart, data viz, axis, legend, Canvas, SVG, WebGL, timezone |
| [pre-launch.md](references/pre-launch.md) | pre-launch, checklist, ship, gate, verification, evidence |
| [audit-workflow.md](references/audit-workflow.md) | audit, route, sweep, baseline, capture, polish, multi-page |
| [components.md](references/components.md) | component, contract, extraction, slots, composition, tokens, Storybook |
| [defects.md](references/defects.md) | defect, geometry, sweep, threshold, regression, lookup |
| [security.md](references/security.md) | CSP, COOP, COEP, CORP, cross-origin isolation, Trusted Types, SRI, Permissions-Policy, Referrer-Policy, supply chain |
| [observability.md](references/observability.md) | RUM, observability, monitoring, source maps, error capture, INP attribution, Reporting API, CrUX, LoAF |
| [testing.md](references/testing.md) | testing, visual regression, axe-core, pa11y, size-limit, bundlesize, lighthouse-ci, contract test |
| [auth.md](references/auth.md) | auth, login, passkey, WebAuthn, OAuth, magic link, session, account recovery, CAPTCHA, Storage Access API |
| [debug-recipes.md](references/debug-recipes.md) | debug, recipe, hydration mismatch, layout overflow, focus trap, font-swap CLS, Lighthouse-flake |
| [anti-patterns.md](references/anti-patterns.md) | anti-pattern, what to avoid, mistake |
| [i18n.md](references/i18n.md) | i18n, l10n, locale, translation, Intl, plural rules, bidi, RTL, mirroring, hreflang |
| [pwa-offline.md](references/pwa-offline.md) | PWA, offline, service worker, install prompt, push, background sync, manifest |
| [build-hygiene.md](references/build-hygiene.md) | build, tree-shaking, sideEffects, lockfile, dead code, code splitting, dependency cost |
| [embed-patterns.md](references/embed-patterns.md) | embed, iframe, sandbox, postMessage, host, guest, third-party widget |
| [print-email.md](references/print-email.md) | print, email, @page, page-break, transactional email, Outlook |
| [quick-reference.md](references/quick-reference.md) | rule, quick reference, highest leverage |
## Workflow
### 1. Frame the work
Before writing code, capture: audience and intent; indexability (search-indexed surface vs auth wall); aesthetic direction (one bold lane; refuse "Inter on white with a purple gradient"); constraints (render strategy from the table above, hydration model, browser support floor, locale, writing direction).
The rendered browser is the source of truth at every phase. Do not judge visual quality from code alone; capture screenshots, compare against the reference, fix, and re-capture. When the work is auditing or polishing an existing multi-page site rather than building new, switch to the Multi-Page Polish Loop below.
### 2. Plan the structure
One H1 per page; sequential headings. Land the LCP element in the first viewport (a real, served asset with declared dimensions, not lazy-loaded, not behind hydration). Reserve space for every async element (`aspect-ratio`, fixed height, skeleton). Decide which sections are server-rendered, which are streamed, which are deferred, which are client-only; justify every client-only choice.
### 3. Build with budgets
Declare and verify per surface: JS payload (initial route, gzipped); CSS payload (initial route, gzipped); image payload (above the fold); font budget (families, weights, subsets, byte total); third-party script budget (count, total ms of main-thread time). Reject any addition that breaks a budget without an explicit, recorded waiver.
### 4. Verify
Run the full Lighthouse + axe + visual + responsive + dark-mode + reduced-motion + keyboard + screen-reader pass before declaring the work complete. See [pre-launch.md](references/pre-launch.md).
## Multi-Page Polish Loop
When the work is auditing or polishing an existing multi-page site, **read [audit-workflow.md](references/audit-workflow.md) first** for the full procedure (operating rules, capture script, per-phase detail, final acceptance gate). The 19 phases index:
1. Discover context. 2. Inventory routes. 3. Inventory repeated widgets. 4. Capture baseline. 5. Capture reference. 6. Audit each route. 7. Prioritise fixes. 8. Patch with component discipline. 9. Patch with design discipline. 10. Verify after every fix group. 11. Programmatic geometry checks (and content-and-markup sweep). 12. Component drift checks. 13. Interaction QA. 14. Accessibility validation. 15. Lighthouse and performance polish. 16. Reference-level design heuristics. 17. Common defects and fixes. 18. Deliverables. 19. Final acceptance gate.
Component extraction discipline lives in [components.md](references/components.md); defect lookup and the geometry sweep in [defects.md](references/defects.md).
## Quick Reference
The highest-leverage rules across performance, accessibility, SEO, UI/UX, design, and multi-page consistency live in [references/quick-reference.md](references/quick-reference.md). Open that file when you want a single condensed view to scan against a change.
## Skill freshness
This skill encodes web-platform behaviour at a point in time. Before applying an old rule, double-check it is still current when:
- The rule cites a Lighthouse audit ID, weight, or category cutoff (Lighthouse rebalances categories every few releases).
- The rule cites a browser-support floor (Baseline status moves; check caniuse.com or the MDN Baseline widget).
- The rule cites a specific Core Web Vitals threshold or metric name (INP replaced FID in 2024; thresholds are revisited).
- The rule names a CSS or JS API ([`@layer`, View Transitions, Anchor Positioning, Popover, `:has()`] all changed support between 2023 and 2026).
See [CHANGELOG.md](CHANGELOG.md) for the dated history of additions and refinements.
## Self-Improvement
After applying this skill, before declaring work complete:
- Did Lighthouse run on real production builds (not dev mode) with throttled mobile and desktop presets?
- Did axe (or equivalent) run with zero violations on every changed page?
- Did the page render correctly at 320px, 375px, 768px, 1024px, 1280px, and 1920px?
- Did the page render correctly in light and dark mode independently (not by CSS-inverting one)?
- Did the page render correctly with `prefers-reduced-motion: reduce`?
- Did the page work end-to-end with keyboard only?
- Did the page survive a screen reader pass (VoiceOver or NVDA) for the primary task?
- Did the LCP element ship with `fetchpriority="high"`, declared dimensions, and a real served asset?
- Did all custom fonts load with `font-display` set, and is the count <= 2 families / 4 weights?
- Did every async section reserve space?
- Did every interactive element have visible focus and meet contrast?
- Did the page expose unique title, description, canonical, OG, Twitter, and (where applicable) JSON-LD?
- Did you verify the SHIPPED build output (copied headers, static files, generated images), not just the source config?
For multi-page audit and polish work, also confirm:
- Was a widget inventory built for every shared family (header, hero, section, CTA, card, media, list, interactive, empty, legal, 404)?
- Were baseline screenshots captured at `1440x900` and `375x812` before any edits?
- Were after screenshots captured at `1440x900` and `375x812` for every changed route?
- Did the geometry sweep return zero issues on every audited route at both viewports?
- Did you run BOTH programmatic sweeps (geometry, and content-and-markup) on every route?
- Did the drift collector return no unexplained differences for any shared widget family?
- Was a per-route audit table (issues found, fixes applied, before, after) delivered with the change?
If any answer is "no" or "not checked", the work is not done.
.gitignore
node_modules/
.DS_Store
*.log
.env
.env.local
.checks-inventory.baseline.json
.github/workflows/tag-on-main.yml
name: Tag release on main
# Fires on every push to main. Decides whether this push IS a
# release (package.json version changed relative to the previous
# main SHA) and, if so, creates the matching `v<version>` tag.
# Non-release commits are a fast no-op.
#
# The paired `release.yml` workflow opens a bump PR; merging that
# PR triggers this workflow, which then creates the tag. The tag
# push does NOT auto-trigger `publish.yml`: the default
# `GITHUB_TOKEN` cannot fire other workflows (GitHub design, to
# prevent workflow recursion). Publishing is a separate manual
# `workflow_dispatch` on the tag, documented in README
# `## Releasing`. Swap this step's credential for a GitHub App
# token or PAT to remove the manual step.
#
# Why the version-change gate: without it, any non-release commit
# landing on main after a release would see the existing
# `v<current-version>` tag at a different commit than HEAD and
# (correctly, in isolation) flag it as the orphan-tag failure
# mode. But after a normal release, the tag SHOULD keep pointing
# at the release commit while main moves forward; that's the
# canonical state, not an error. We only treat "tag exists
# elsewhere" as an error when THIS push was itself a release
# (i.e. the version number just changed).
#
# Tag push is NOT blocked by the branch ruleset: rules target
# refs/heads/main only; refs/tags/* is a separate namespace. The
# default GITHUB_TOKEN is sufficient; no bypass actor needed.
on:
push:
branches: [main]
permissions:
contents: write
# Serialize pushes to main so two concurrent release merges can't
# both observe the tag as absent and then race on `git push`.
# cancel-in-progress:false because every run MUST complete:
# cancelling a run would skip tagging a legitimate release commit.
concurrency:
group: tag-on-main
cancel-in-progress: false
jobs:
maybe-tag:
name: Tag current main if package.json version is new
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
# No `cache: npm` here: this job only uses `node -p` to
# read package.json and `git show ... | node` to extract
# the previous version. It never runs npm install/ci, so
# caching would add setup overhead with no install step
# to benefit from it.
- name: Detect whether this push changed the package version
id: version
env:
GITHUB_EVENT_BEFORE: ${{ github.event.before }}
# Set strict shell options explicitly so this step behaves
# predictably across environments regardless of Actions'
# defaults. `pipefail` makes `git show ... | node` fail
# loudly when an upstream command fails (instead of silently
# passing empty input through), and `-u` catches
# unset-variable typos. The `|| true` on `git show` remains
# the explicit opt-out for the one case where absence is
# intentional.
shell: bash
run: |
set -euo pipefail
CURRENT_VERSION=$(node -p "require('./package.json').version")
TAG="v${CURRENT_VERSION}"
echo "version=${CURRENT_VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
# `github.event.before` is 40 zeros on the very first push
# to a new branch (or for unusual event shapes). In those
# cases we can't compute a diff; be conservative and treat
# the push as a release attempt so the tag gets created.
ZERO_SHA="0000000000000000000000000000000000000000"
if [ -z "$GITHUB_EVENT_BEFORE" ] || [ "$GITHUB_EVENT_BEFORE" = "$ZERO_SHA" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "No previous SHA on this push; treating as release by default."
exit 0
fi
# Compare the version at this push's starting SHA to HEAD.
# If git can't read package.json at the before-SHA (file
# introduced in this push, tree rewritten, etc.), we treat
# the absence as a version change, the conservative
# direction that produces a tag rather than silently
# skipping. `|| true` keeps the pipeline's exit status 0
# under bash -eo pipefail so the step continues.
PREV_JSON=$(git show "${GITHUB_EVENT_BEFORE}:package.json" 2>/dev/null || true)
if [ -z "$PREV_JSON" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "Previous package.json not readable at ${GITHUB_EVENT_BEFORE}; treating as change."
exit 0
fi
PREV_VERSION=$(printf '%s' "$PREV_JSON" | node -e "
let buf = ''
process.stdin.on('data', c => buf += c)
process.stdin.on('end', () => {
try { process.stdout.write(JSON.parse(buf).version || '') }
catch { process.stdout.write('') }
})
")
if [ -z "$PREV_VERSION" ] || [ "$PREV_VERSION" != "$CURRENT_VERSION" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "Version changed: ${PREV_VERSION:-<unknown>} -> ${CURRENT_VERSION}; this is a release commit."
else
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Version unchanged at ${CURRENT_VERSION}; non-release commit, no-op."
fi
- name: Check whether the tag already exists on the remote
id: check
if: steps.version.outputs.changed == 'true'
run: |
TAG="${{ steps.version.outputs.tag }}"
HEAD_SHA=$(git rev-parse HEAD)
# `git ls-remote --tags` distinguishes tag shapes:
# annotated tag: two lines, tag OBJECT sha
# (refs/tags/<tag>) and dereferenced commit sha
# (refs/tags/<tag>^{})
# lightweight tag: one line, commit sha under
# refs/tags/<tag>
# Filtering on the exact refname (refs/tags/<tag>)
# suppresses the ^{} line, so we MUST ask for both
# refspecs explicitly. Otherwise the comparison below
# would pick the tag-object sha on annotated tags and
# always fail the legitimate-rerun equality check.
REMOTE_OUT=$(git ls-remote --tags origin \
"refs/tags/${TAG}" \
"refs/tags/${TAG}^{}" \
2>/dev/null || true)
if [ -z "$REMOTE_OUT" ]; then
echo "exists=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Prefer the dereferenced sha (commit) when the remote
# returns both. Fall back to the single-line form for
# lightweight tags.
REMOTE_SHA=$(echo "$REMOTE_OUT" | awk '/\^\{\}$/ {print $1}')
if [ -z "$REMOTE_SHA" ]; then
REMOTE_SHA=$(echo "$REMOTE_OUT" | awk '{print $1; exit}')
fi
if [ "$REMOTE_SHA" = "$HEAD_SHA" ]; then
# Tag already points at the commit we'd tag anyway,
# a legitimate rerun on the same main commit. No-op.
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} already points at HEAD ($HEAD_SHA); nothing to do."
else
# This push changed the version AND a tag for the new
# version already exists at a different commit. Classic
# orphan-tag failure mode: publish.yml would not fire
# for the current release, and the tag would stay
# pointed at a stale commit. Fail loudly.
echo "::error::Tag ${TAG} exists on the remote but points at ${REMOTE_SHA}, not the current main HEAD ${HEAD_SHA}."
echo "::error::This is the orphan-tag failure mode. Delete the stale tag and re-run:"
echo "::error:: git push origin --delete ${TAG}"
exit 1
fi
- name: Create and push the tag
if: steps.version.outputs.changed == 'true' && steps.check.outputs.exists == 'false'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
TAG="${{ steps.version.outputs.tag }}"
git tag -a "${TAG}" -m "release ${TAG}"
# Tolerate "already exists" as success. The concurrency
# group above serialises runs on main, but a belt-and-
# suspenders guard closes the residual race where a user
# (or another workflow) created the same tag between our
# ls-remote check above and this push. We only swallow the
# specific "already exists" failure; any other push error
# still surfaces.
set +e
PUSH_OUT=$(git push origin "refs/tags/${TAG}" 2>&1)
PUSH_STATUS=$?
set -e
echo "$PUSH_OUT"
if [ $PUSH_STATUS -eq 0 ]; then
echo "Created and pushed tag ${TAG}"
elif echo "$PUSH_OUT" | grep -q "already exists"; then
# Someone (or something) created this tag between our
# ls-remote check above and this push. "already exists"
# alone is not enough to treat as success: the concurrent
# creator may have tagged a different commit, in which
# case this release is orphaned (publish.yml fires for
# their commit, not ours). Re-read the remote tag and
# compare to HEAD before declaring success.
HEAD_SHA=$(git rev-parse HEAD)
VERIFY_OUT=$(git ls-remote --tags origin \
"refs/tags/${TAG}" \
"refs/tags/${TAG}^{}" \
2>/dev/null || true)
VERIFY_SHA=$(echo "$VERIFY_OUT" | awk '/\^\{\}$/ {print $1}')
if [ -z "$VERIFY_SHA" ]; then
VERIFY_SHA=$(echo "$VERIFY_OUT" | awk '{print $1; exit}')
fi
if [ "$VERIFY_SHA" = "$HEAD_SHA" ]; then
echo "Tag ${TAG} was created concurrently at HEAD ($HEAD_SHA); treating as success."
else
echo "::error::Tag ${TAG} was created concurrently but points at ${VERIFY_SHA:-<unknown>}, not HEAD ${HEAD_SHA}."
echo "::error::Another actor tagged a different commit. This release is orphaned. Delete the stale tag and re-run:"
echo "::error:: git push origin --delete ${TAG}"
exit 1
fi
else
exit $PUSH_STATUS
fi
CONTRIBUTING.md
# Contributing to skill-frontend-excellence
This is a content-only skill: every change is to a Markdown file under `SKILL.md` or `references/`. There is no runtime to wire up, no FSM to author, no corpus to rebuild. The discipline lives in the prose.
## Local Setup
```bash
git clone https://github.com/ctxr-dev/skill-frontend-excellence.git
cd skill-frontend-excellence
npm install
```
`npm install` pulls in `markdownlint-cli2` and sets up the Husky pre-commit hook, which runs the validators (markdown lint plus the em/en dash guard) before every commit.
## Editing
The two files you will touch:
- `SKILL.md` (entry point, North Star Targets, priority stack, routing tables, conflict-resolution table, render-strategy decision tree, freshness check)
- `references/<topic>.md` (deep dives; the canonical Quick Reference rules live in `references/quick-reference.md`)
After every edit, run:
```bash
npm run validate
```
This runs:
- `node scripts/check-no-dashes.mjs` to reject em (U+2014) and en (U+2013) dashes
- `node scripts/validate-structure.mjs` to enforce the frontmatter and routing contract (complete frontmatter, closed-vocabulary keywords, no routing orphan, resolving links and section pointers, accurate `size:`)
- `markdownlint-cli2` over `SKILL.md` and `references/**/*.md`
All three must pass before commit. The pre-commit hook enforces this. Run `npm run validate:structure:fix` to auto-correct drifted `size:` fields.
## Style Rules
These are strict. Failing any of them is a defect, like a failing test.
- **Framework-agnostic.** Express every rule as a principle plus a concrete check. Never name a library API (no `useEffect`, no `getServerSideProps`, no `<Image />`). When a rule is naturally tied to a platform feature (Service Workers, `<picture>`, `font-display`), name the standard, not the framework that wraps it.
- **Generic placeholders only.** No project-specific brands, products, or domain names (no Acme, no Stripe, no example.com plot devices that imply a real product). Use `your-app`, `your-domain`, `Product Name`, or omit the example entirely.
- **No em or en dashes.** Use commas, colons, parentheses, or line breaks. The repo enforces this via `scripts/check-no-dashes.mjs`. (Yes, every contributor hits this once.)
- **Consistent voice.** Match the existing references: short declarative sentences, lists over prose where possible, every rule actionable. Avoid hedge words ("perhaps", "might be", "tends to") when a concrete threshold exists.
- **Concrete thresholds win.** Prefer "contrast >= 4.5:1" over "good contrast", "<= 90 KB gzipped" over "small bundle", "44x44pt" over "comfortable touch targets".
- **One H1 per file, sequential headings.** No skipped levels. The skill itself follows the rules it teaches.
- **JS code samples in references are illustrations, not deliverables.** Keep each in the 30 to 50 line range; longer snippets are smelly and fragment into smaller pieces. Use only standard DOM and CSSOM APIs (`document.querySelectorAll`, `getComputedStyle`, `getBoundingClientRect`, etc.) plus standard Node modules where the snippet runs the browser (e.g., `fs`, `path`, `puppeteer`). Introduce every browser-automation snippet with the framework-neutral phrasing "Run from a headless browser of your choice (Puppeteer, Playwright, or equivalent)". Do not name a test runner, build tool, or component library inside the snippet itself beyond the launcher line. Even when a pattern is naturally associated with one framework, the reference must phrase it as a principle plus check, not a library API.
## Reference file frontmatter (required)
Every file under `references/*.md` MUST start with a YAML frontmatter block. The block is part of the navigation infrastructure: a runtime agent reads it (a cheap `head -20`) to decide whether to load the rest of the file. Missing or malformed frontmatter is a defect, like a failing test.
Schema:
```yaml
---
title: Performance Deep Dive
purpose: One or two sentences naming the file's scope. Keep it short, dense, scannable.
load-when:
task-keywords: [LCP, INP, CLS, hydration, bundle, preload, Speculation Rules, BFCache, render strategy]
symptoms: [LCP regression, INP regression, CLS regression, score dropped, slow page]
prereq: SKILL.md
related: [lighthouse.md, observability.md, debug-recipes.md, build-hygiene.md]
size: ~620 lines
---
```
Field rules:
- **`title`**: short title, capitalised, no trailing punctuation. Matches the file's H1.
- **`purpose`**: one or two sentences. Plain prose. No em / en dashes. This is the snippet a routing agent reads first.
- **`load-when.task-keywords`**: the canonical task vocabulary that should pull this file. PICK FROM the keyword glossary below; do not invent new keys without adding them to the glossary. Six to twelve keywords per file is the working range. Use the same key for the same concept across files (e.g., `LCP`, not `Largest Contentful Paint` in some files and `LCP` in others).
- **`load-when.symptoms`**: the canonical regression vocabulary that should pull this file. Same picking-from-glossary rule. Three to eight symptoms per file.
- **`prereq`**: always `SKILL.md` for now. Reserved for future tiered prereqs.
- **`related`**: two to four sibling files most likely to be loaded with this one. Builds the connected graph; no file is a leaf. Cross-link from each file's "See also" footer to a subset of `related:`.
- **`size`**: approximate line count, `~N lines`. The agent uses this to budget context.
When you add a new reference file: place it under `references/`, give it the frontmatter, add a row to the `SKILL.md` Reference Index, **add it to at least one of the By-Task or By-Symptom routing tables** (no file may be reachable only via the Reference Index), and add it to the `related:` list of at least one existing file. Run `npm run validate` to confirm structural integrity. The structural validator fails on a missing frontmatter field, an off-vocabulary keyword, a routing orphan, a broken link, a bad section pointer, or a stale `size:`.
### Routing section pointers
A routing-table row may point at one section of a large file instead of the whole file, using the heading anchor:
```text
[performance.md: Font Strategy](references/performance.md#font-strategy)
```
The anchor is the GitHub-style slug of a real `##` heading in that file (lowercase, spaces to hyphens, punctuation dropped). The validator resolves every anchor against the target file's headings, so a renamed or mistyped heading fails CI. Use section pointers by heading name, never line numbers.
## Keyword glossary (the corpus vocabulary)
The skill builds its agent-routing index from a closed vocabulary. Using the SAME word for the same concept across files is the whole point: it keeps the semantic tree consistent so an agent's substring match resolves cleanly. When you need a concept not listed, ADD it here AND to the `TASK_KEYWORDS` / `SYMPTOMS` sets in `scripts/validate-structure.mjs` (the machine-authoritative copy that CI enforces), in the same PR.
### Task-keyword vocabulary
Grouped by domain (the group itself is not the keyword; the items are):
- **Core Web Vitals and perf**: `LCP`, `INP`, `CLS`, `TTFB`, `FCP`, `TBT`, `performance`, `hydration`, `bundle`, `preload`, `prefetch`, `Speculation Rules`, `Early Hints`, `fetchpriority`, `BFCache`, `render strategy`, `SSR`, `SSG`, `CSR`, `ISR`, `streaming`, `islands`, `partial hydration`, `resumable`
- **Accessibility**: `accessibility`, `a11y`, `WCAG`, `screen reader`, `keyboard`, `focus`, `contrast`, `semantic HTML`, `ARIA`, `dynamic type`, `reduced motion`, `forced colors`, `axe`, `inline SVG`
- **SEO**: `SEO`, `indexing`, `canonical`, `sitemap`, `robots`, `structured data`, `JSON-LD`, `AEO`, `GEO`, `llms.txt`, `hreflang`, `Open Graph`, `meta description`, `title tag`, `clean URLs`
- **Lighthouse**: `lighthouse`, `diagnostic Insights`, `phantom failure`, `errors-in-console`, `image-size-responsive`
- **UI / UX**: `UI`, `UX`, `interaction`, `hover`, `press`, `loading state`, `error state`, `empty state`, `success state`, `modal`, `popover`, `dialog`, `drawer`, `sheet`, `menu`, `tooltip`, `snackbar`, `toast`, `breadcrumb`, `navigation`, `touch target`, `hit target`, `popover API`, `inert`
- **Design**: `design`, `typography`, `color`, `palette`, `OKLCH`, `P3`, `wide gamut`, `spacing`, `composition`, `atmosphere`, `dark mode`, `light mode`, `brand`, `font`, `variable font`
- **Responsive**: `responsive`, `breakpoint`, `mobile`, `tablet`, `desktop`, `container query`, `viewport`, `safe area`, `dvh`, `srcset`, `DPR`, `fluid typography`, `subgrid`, `scrollbar-gutter`
- **Motion**: `motion`, `animation`, `transition`, `easing`, `View Transitions`, `scroll-driven`, `WAAPI`, `will-change`, `@starting-style`
- **Forms**: `form`, `validation`, `autofill`, `autocomplete`, `label`, `input`, `select`, `checkbox`, `radio`, `file upload`, `constraintValidation`
- **Data viz**: `chart`, `data viz`, `axis`, `legend`, `colorblind`, `Canvas`, `SVG`, `WebGL`, `timezone`, `DST`
- **Pre-launch**: `pre-launch`, `checklist`, `ship`, `release`, `deployment`, `gate`, `verification`, `evidence`
- **Audit / polish**: `audit`, `route`, `sweep`, `screenshot`, `baseline`, `capture`, `polish`, `drift`, `measurable bars`
- **Components**: `component`, `widget`, `contract`, `extraction`, `slots`, `composition`, `Storybook`, `tokens`
- **Defects**: `defect`, `bug`, `regression`, `geometry`, `threshold`
- **Security**: `security`, `CSP`, `COOP`, `COEP`, `CORP`, `cross-origin isolation`, `SRI`, `Trusted Types`, `Permissions-Policy`, `Referrer-Policy`, `frame-ancestors`
- **Observability**: `observability`, `RUM`, `monitoring`, `error capture`, `source maps`, `INP attribution`, `Reporting API`, `error boundary`, `CrUX`, `Long Animation Frames`, `LoAF`
- **Testing**: `testing`, `visual regression`, `axe-core`, `pa11y`, `size-limit`, `bundlesize`, `lighthouse-ci`, `contract test`, `type check`, `hermetic gate`
- **Auth**: `auth`, `authentication`, `login`, `signup`, `passkey`, `WebAuthn`, `OAuth`, `magic link`, `session`, `account recovery`, `CAPTCHA`, `Turnstile`, `Storage Access API`
- **Debug**: `debug`, `recipe`, `hydration mismatch`, `layout overflow`, `focus trap`, `font-swap CLS`
- **Anti-patterns**: `anti-pattern`, `what to avoid`, `mistake`
- **i18n**: `i18n`, `l10n`, `internationalization`, `localization`, `locale`, `translation`, `Intl`, `plural rules`, `bidi`, `RTL`, `mirroring`
- **PWA / offline**: `PWA`, `offline`, `service worker`, `SW`, `install prompt`, `push`, `background sync`, `manifest`
- **Build hygiene**: `build`, `tree-shaking`, `dependency`, `sideEffects`, `lockfile`, `dead code`, `code splitting`
- **Embed patterns**: `embed`, `iframe`, `sandbox`, `postMessage`, `host`, `guest`, `widget`, `third-party widget`
- **Print / email**: `print`, `email`, `@page`, `page-break`, `transactional email`, `Outlook`
- **Quick reference**: `rule`, `quick reference`, `highest leverage`
### Symptom vocabulary
Use these EXACT strings (the agent matches by substring):
`LCP > 2.5s`, `LCP regression`, `INP > 200ms`, `INP regression`, `CLS > 0.1`, `CLS regression`, `slow page`, `slow interaction`, `score dropped`, `Lighthouse score drop`, `bundle size grew`, `hydration mismatch`, `focus trap leak`, `duplicate id`, `horizontal scroll`, `viewport overflow`, `broken on Firefox`, `broken on Safari`, `font swap CLS`, `iOS 100vh`, `rubber-band scroll`, `third-party script slow`, `focus not visible`, `contrast fail`, `aria-hidden leak`, `inert leak`, `noindex with sitemap`, `canonical mismatch`, `consent banner CLS`, `popover not dismissing`, `scroll lock side shift`, `auth redirect loop`, `passkey not offered`, `RTL broken`, `dark mode broken`, `phantom dev failure`, `errors-in-console`, `image too small on retina`, `stale SRI beacon`
### Synonym clusters
The agent gets `<concept> ↔ <abbrev>` for free via these clusters. Subagents writing prose should prefer the abbreviation in `task-keywords:` (saves frontmatter space) and either form in prose.
- `LCP` / `Largest Contentful Paint`
- `INP` / `Interaction to Next Paint`
- `CLS` / `Cumulative Layout Shift`
- `TTFB` / `Time to First Byte`
- `FCP` / `First Contentful Paint`
- `TBT` / `Total Blocking Time`
- `SSR` / `Server-Side Rendering`
- `SSG` / `Static Site Generation`
- `CSR` / `Client-Side Rendering`
- `a11y` / `accessibility`
- `i18n` / `internationalization`
- `l10n` / `localization`
- `RUM` / `Real-User Monitoring`
- `CWV` / `Core Web Vitals`
- `BFCache` / `back/forward cache`
- `SW` / `service worker`
- `LoAF` / `Long Animation Frames`
## Commits
Conventional commit messages are documented convention; commitlint is not enforced. Examples:
```text
feat(performance): add resource hints decision matrix
fix(accessibility): correct contrast threshold for large text
docs(readme): clarify submodule install path
chore(lint): bump markdownlint-cli2
```
Types in use: `feat`, `fix`, `docs`, `chore`, `refactor`, `style`, `test`. Scope is the affected file or topic (e.g., `performance`, `seo`, `readme`).
## Pull Requests
Open PRs against `main`. CI runs `npm run validate` and must pass. Keep PRs focused: one topical reference, or one cross-cutting principle, per PR. Larger restructures get their own PR with a brief rationale in the description.
## Releasing (Maintainers Only)
The release flow is PR-gated; the bot does not push to `main` directly.
1. **Actions, Release, Run workflow.** Branch: `main` (any other ref is rejected). Version bump: `patch`, `minor`, or `major`.
2. The workflow bumps `package.json` on a `release/v<version>` branch and opens a release PR.
3. Review and merge the PR.
4. `tag-on-main.yml` detects the version change on the merge commit, creates the annotated `v<version>` tag, and pushes it.
5. The tag push triggers `publish.yml`, which validates and publishes to npm via OIDC trusted publishing.
Full operator walkthrough (including troubleshooting for stale tags, non-main dispatches, and the "Allow GitHub Actions to create and approve pull requests" org-level policy) lives in the [Releasing section of the README](README.md#releasing).
.husky/pre-commit
npm run validate
README.md
# Frontend Excellence Skill (Claude Code, Codex CLI)
[](https://www.npmjs.com/package/@ctxr/skill-frontend-excellence)
[](LICENSE)
[](https://agentskills.io)
> Supports Claude Code and OpenAI Codex CLI via the open Agent Skills standard. Content-only Markdown: one entry point (`SKILL.md`) plus topical references under `references/`. No runtime, no scripts, no framework assumptions.
A holistic, framework-agnostic playbook for shipping ultra-fast, accessible, search-friendly, visually distinctive web interfaces. Combines Lighthouse mastery, UI/UX patterns, design aesthetics, performance, accessibility, SEO, motion, forms, layout, data visualization, and a 19-phase multi-page audit workflow with widget standardization and geometry sweeps. Every rule is expressed as a principle plus a concrete check, never as a library API, so the guidance applies to vanilla HTML/CSS/JS, React, Vue, Svelte, Astro, Solid, Next.js, Nuxt, SvelteKit, Remix, Qwik, Lit, and Web Components alike.
The skill encodes a single, opinionated bar (see [North Star Targets](#north-star-targets)). Treat any failure as a blocking defect, not a polish task.
## Quick Start
```bash
# Install into your project
npx @ctxr/kit@latest install @ctxr/skill-frontend-excellence
```
Open the project in any Agent Skills-compatible harness. The skill auto-activates from its YAML frontmatter the moment a prompt touches how an interface looks, feels, moves, loads, ranks, or is interacted with. There is no slash command to invoke; the skill is content-only.
## Prerequisites
- An Agent Skills-compatible harness ([Claude Code](https://claude.ai/code) CLI/IDE, or OpenAI Codex CLI)
- A project directory where you want the skill installed. Project-scope installs land at `.agents/skills/skill-frontend-excellence/`; user-scope installs (`--user`) land at `~/.agents/skills/skill-frontend-excellence/`. Discovery-mirror symlinks at `.claude/skills/` (and `~/.codex/skills/` at user scope) are created automatically so both harnesses find the skill without extra configuration.
## Installation
### Manual
```bash
git clone https://github.com/ctxr-dev/skill-frontend-excellence.git /tmp/skill-frontend-excellence
mkdir -p .agents/skills
cp -r /tmp/skill-frontend-excellence .agents/skills/skill-frontend-excellence
```
### Git Submodule
```bash
git submodule add https://github.com/ctxr-dev/skill-frontend-excellence.git \
.agents/skills/skill-frontend-excellence
```
Pins the skill at a specific commit; update explicitly via `git submodule update --remote`.
## Usage
Agent Skills-compatible harnesses (Claude Code, Codex CLI) surface this skill automatically based on the YAML frontmatter at the top of `SKILL.md`. The `description` field tells the host when to load it: any prompt that touches how an interface looks, feels, moves, loads, ranks, or is interacted with will trigger activation.
When the skill is active, the host reads `SKILL.md` first (the entry point with the North Star Targets, priority stack, workflow, the Multi-Page Polish Loop, and the routing tables), then loads only the topical reference under `references/` that matches the current work. There is nothing to invoke manually; install the skill and proceed with the task.
For the canonical entry point, read [`SKILL.md`](SKILL.md).
## What's Inside
- [`SKILL.md`](SKILL.md): the entry point. North Star Targets, priority stack, conflict tie-breakers, the render-strategy decision, the routing tables (by task, by symptom, and the full Reference Index), the Multi-Page Polish Loop, and the self-improvement checklist.
- `references/`: topical deep dives (Lighthouse, performance, accessibility, SEO, design, responsive, motion, forms, data-viz, components, security, testing, observability, auth, i18n, PWA, embed, print and email, and more), each carrying YAML frontmatter so a host loads only what the current task needs.
- Index surfaces: [`references/quick-reference.md`](references/quick-reference.md) (the highest-leverage rules in one scan) and [`references/anti-patterns.md`](references/anti-patterns.md) (one row per anti-pattern with its standard fix).
The canonical, always-current list of every reference file and when to load it is the **Reference Index inside [`SKILL.md`](SKILL.md)**. This README intentionally does not duplicate it, to avoid drift.
## North Star Targets
Every interface should meet these bars before being considered "done". Treat any failure as a blocking defect, not a polish task.
| Category | Mobile target | Desktop target | Source |
|----------|--------------|----------------|--------|
| Lighthouse Performance | >= 95 | >= 99 | Lighthouse |
| Lighthouse Accessibility | 100 | 100 | Lighthouse + axe |
| Lighthouse Best Practices | 100 | 100 | Lighthouse |
| Lighthouse SEO | 100 | 100 | Lighthouse |
| Largest Contentful Paint (LCP) | < 2.5s | < 2.0s | Core Web Vitals |
| Interaction to Next Paint (INP) | < 200ms | < 200ms | Core Web Vitals |
| Cumulative Layout Shift (CLS) | < 0.1 | < 0.05 | Core Web Vitals |
| Time to First Byte (TTFB) | < 800ms | < 600ms | Core Web Vitals |
| First Contentful Paint (FCP) | < 1.8s | < 1.0s | Lighthouse |
| Total Blocking Time (TBT) | < 200ms | < 100ms | Lighthouse |
| Initial JS payload (gzipped) | < 90 KB | < 130 KB | Budget |
| Initial CSS payload (gzipped) | < 25 KB | < 35 KB | Budget |
| Web font payload (per screen) | <= 2 families, <= 4 weights | same | Budget |
These are the universal bar. A given project may consciously relax a budget for a specific surface (e.g., a heavily interactive client-rendered tool that legitimately ships more JS), but the relaxation should be a recorded, justified exception, not a default.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for local setup, editing conventions, and the strict style rules (framework-agnostic phrasing, generic placeholders, no em or en dashes).
## Releasing
Releases are PR-gated. Version bumps land on `main` through a review gate like any other change; only the tag push and npm publish are automated.
1. **Actions, Release, Run workflow.** Branch selector: `main` (the workflow refuses any other ref). Version bump: `patch`, `minor`, or `major`.
2. The workflow bumps `package.json` on a fresh `release/v<version>` branch and opens a release PR.
3. Review and merge the PR (the diff is just version fields).
4. On merge, `tag-on-main.yml` detects the version change, creates the annotated `v<version>` tag, and pushes it.
5. The tag push triggers `publish.yml`, which runs validation, verifies tag/version agreement, and publishes to npm via OIDC trusted publishing.
See [GitHub Releases](https://github.com/ctxr-dev/skill-frontend-excellence/releases) for the changelog, and [CHANGELOG.md](CHANGELOG.md) for the in-repo history.
## License
[MIT](LICENSE)
.github/workflows/publish.yml
name: Publish to npm
on:
push:
tags: ["v*"]
workflow_dispatch:
jobs:
publish:
name: Publish
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write
steps:
- name: Refuse non-tag refs
# `workflow_dispatch` is kept (so an operator can re-run a
# publish for an existing tag after a transient npm outage),
# but it can also be fired against a branch, in which case
# the tag/version check below is skipped (its `if` gates on
# refs/tags/v*) and `npm publish` would still run. That is
# an untagged-publish hole. Refuse any ref that isn't a
# `refs/tags/v*` tag up-front, regardless of event type.
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: |
echo "::error::publish.yml only runs for refs/tags/v*; got ${{ github.ref }}."
echo "::error::Re-dispatch against an existing v<version> tag, or push a new one via the release flow."
exit 1
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: npm
registry-url: https://registry.npmjs.org
- run: npm ci
- name: Validate (no-dashes + lint)
run: npm run validate
- name: Verify version matches tag
if: startsWith(github.ref, 'refs/tags/v')
run: |
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
PKG_VERSION=$(node -p "require('./package.json').version")
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
echo "::error::Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)."
echo "::error::Investigate the merge commit; this should not happen under the PR-based flow."
exit 1
fi
- name: Generate SBOM
run: npx --yes @cyclonedx/cyclonedx-npm --output-file sbom.cdx.json
- name: Upload SBOM
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: sbom
path: sbom.cdx.json
if-no-files-found: error
- name: Publish
run: npm publish --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
CHANGELOG.md
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.3.1] - 2026-06-13
### Changed
- Sharpened the `errors-in-console` third-party-beacon coverage in `lighthouse.md`: the discriminator now names the DOM-inspection confirm step (look for an `integrity` + `crossorigin` pair on an auto-injected third-party script) and the header false-negative trap (a direct `curl -I` of the asset still returns a valid `Access-Control-Allow-Origin`, so a header-only check passes and misses the stale-hash-under-CORS failure), and the fix now states the why (edge auto-injectors skip injection when the tag is already present, so the plain self-injected tag pre-empts the broken pinned one). Mirrored the mechanism into the `anti-patterns.md` beacon row and reconciled the `debug-recipes.md` Hypothesis B fix with this canonical self-inject path.
- Added cross-links the structural index was missing: `build-hygiene.md` See Also now points at the `seo.md` clean-URL contract, and the `seo.md` tabbed-content note names the progressive-enhancement detail (roving tabindex, a deep-linkable active tab) with a pointer to `accessibility.md` Tabs.
## [0.3.0] - 2026-06-03
A corpus-wide density and navigation pass, a machine-enforced structural contract, and the field-tested refinements from a verified 100 / 100 / 100 / 100 build.
### Added
- **Structural validator** (`scripts/validate-structure.mjs`, wired into `npm run validate`, CI, and the pre-commit hook). It enforces what the skill's determinism rests on: complete YAML frontmatter on every reference file, `load-when` keywords and symptoms drawn from the closed vocabulary, no routing orphan (every file reachable from By-Task or By-Symptom, not just the Reference Index), every routing link and `related:` entry resolving, every section-pointer anchor matching a real heading, and an auto-maintained `size:`.
- **Routing section pointers.** The By-Symptom table now jumps to a specific section of a file (for example `performance.md` at "Font Strategy") by heading anchor, so an agent reads the one relevant section, not a whole 600-line file. By-Task and By-Symptom use links throughout and together cover every reference file (the previously index-only `anti-patterns.md` and `data-viz.md` are now task-routed).
- **Field-tested refinements**, folded into their topical files principle-first and framework-agnostic:
- CSS-delivery changes can reorder the cascade and silently regress contrast, so re-run axe on every route after any delivery change (`lighthouse.md`, `anti-patterns.md`, `quick-reference.md`).
- Scored audits versus diagnostic Insights: do not chase a clean Insight when the scored metric is already green (`lighthouse.md`).
- A dev server manufactures phantom failures; score a production build via the framework preview or the live edge, and the live or field number outranks any local run (`lighthouse.md`, `debug-recipes.md`, `quick-reference.md`).
- `errors-in-console` from a third-party beacon: separate a client-blocker false positive from a real stale-SRI auto-injection bug with a clean headless reproduce (`debug-recipes.md`, `lighthouse.md`, `anti-patterns.md`).
- Responsive images must cover CSS width times device pixel ratio, with the arithmetic (`responsive.md`, `lighthouse.md`, `quick-reference.md`).
- Above-the-fold animation ships only past a Lighthouse budget gate, or it is cut (`motion.md`, `quick-reference.md`).
- Static-host clean URLs are a three-part contract with a flat-file audit gotcha, and a 410 from a submission endpoint means delete the integration (`seo.md`).
- Inline SVG is `aria-hidden` or `role="img"` with a name, never a role without a name (`accessibility.md`, `anti-patterns.md`, `quick-reference.md`).
- CI realism for a perfect-score pipeline: keep self-injected third-party tags out of the hermetic gate, gate Performance at a floor while holding the other three at 100, do not oversubscribe runner cores, and place slow gates by feedback cost (`testing.md`).
- Keep tool scratch out of the dev watcher and shard generated audit artifacts (`build-hygiene.md`); restate a vague quality goal as per-route numeric bars before acting (`audit-workflow.md`); build-time-optimize images inside islands (`performance.md`).
### Changed
- **Corpus-wide density pass.** Every reference file was rewritten to cut prose, hedging, and cross-file repetition and to reformat rules into tables and checklists. The corpus dropped about 1700 lines (roughly 14 percent) while preserving every field-tested check: a 3608-item checks inventory was extracted before the pass and verified after with zero losses. Cross-cutting checks now live once in a canonical home and are echoed as one-liners elsewhere.
- `quick-reference.md` grew to 44 rules (four new high-leverage one-liners) and `anti-patterns.md` gained three rows.
- `CONTRIBUTING.md` now documents the structural validator, the section-pointer syntax, the closed-vocabulary additions, and the rule that a new file must join a routing table.
- `README.md` no longer duplicates the reference list; the Reference Index in `SKILL.md` is the single source.
## [0.2.1] - 2026-05-30
### Changed
- Pin the documented `npx @ctxr/kit` invocations to `npx @ctxr/kit@latest` so install and update resolve reliably (an unpinned scoped npx spec can fail to link its bin on newer npm).
## [0.2.0] - 2026-05-29
### Added
- **Navigation infrastructure.** As the skill grows from 14 to 26 reference files, an agent must be able to pull only the 2 to 3 files matching the current task. New `SKILL.md` "Loading policy" paragraph + three routing tables: By Task (13 rows), By Symptom (15 rows), and an expanded Reference Index with per-file load-when keywords. YAML frontmatter (`title`, `purpose`, `load-when.task-keywords`, `load-when.symptoms`, `prereq`, `related`, `size`) on every reference file so an agent can do a `head -20` to decide whether to load the rest. Closed-vocabulary keyword glossary + frontmatter spec live in `CONTRIBUTING.md`.
- **`SKILL.md` re-architecture** as a navigation hub (about 240 lines). New "When rules conflict" tie-breaker table after Priority Stack (6 canonical conflicts: animation vs INP, aesthetic vs contrast, etc.). "Decision: Render Strategy" promoted from `performance.md` as a compact 5-row decision table on indexability / personalisation / interactivity axes. "Skill freshness" note pointing at `CHANGELOG.md` and naming the rule classes most likely to drift.
- **Eleven new reference files** closing structural gaps:
- `references/security.md`: beyond CSP. COOP / COEP / CORP and cross-origin isolation, Trusted Types as a CSP-XSS companion, Subresource Integrity, `Permissions-Policy`, `Referrer-Policy`, `frame-ancestors`, third-party-script supply-chain hygiene, secrets discipline, dependency-cost discipline.
- `references/observability.md`: post-deploy practice. Source-maps-in-prod private upload, `window.onerror` and `unhandledrejection`, error boundaries, sampled RUM with `sendBeacon` + `visibilitychange` flush, Reporting API, INP field attribution via `web-vitals/attribution` and Long Animation Frames API, CrUX / PageSpeed Insights for trend tracking, alert thresholds, session-replay tradeoffs.
- `references/testing.md`: pre-merge gate discipline. Visual regression (Playwright snapshots, reg-suit, Chromatic), accessibility automation in CI (`@axe-core/playwright`, pa11y-ci), perf budgets (`size-limit`, `bundlesize`, INP CrUX gate), type-only check gates, contract tests, lighthouse-ci GitHub Action wiring at depth. Testing is the pre-merge gate; `audit-workflow.md` is the post-build polish loop.
- `references/auth.md`: flow-level authentication. Passkeys + WebAuthn conditional UI, OAuth redirect UX (PKCE), magic-link UX, session-expiry handling, account recovery beyond password reset, CAPTCHA / Turnstile / hCaptcha placement and a11y, sign-in form structure, sign-up progressive disclosure, cross-tab session sync via `BroadcastChannel`, Storage Access API for embedded sign-in.
- `references/embed-patterns.md`: embed-as-host and embed-as-guest. Sandboxing third-party iframes, `sandbox` flags, allow-list via `allow`, viewport coupling, theme inheritance, CHIPS for cross-origin storage. As guest: postMessage handshake, viewport reporting via `ResizeObserver`, theme adoption, account-linked vs anonymous. Origin verification, top-window navigation constraints, COOP / COEP / CORP impact.
- `references/debug-recipes.md`: eight symptom-driven recipes (Symptom -> Reproduce -> Isolate -> Hypothesise -> Verify) for hydration mismatch, INP regression, CLS root-cause hunt, accessibility false positive, layout overflow, focus trap leak, Lighthouse flake triage, font-swap CLS.
- `references/anti-patterns.md`: a single index of every pattern the skill names as an anti-pattern, with its standard fix and a deep link. 80 rows. Pure index; no restated content.
- `references/i18n.md`: translation pipeline UX (extraction, missing-key fallback, 30 percent length-expansion buffer), `Intl.*` at depth (`NumberFormat`, `DateTimeFormat`, `PluralRules`, `ListFormat`, `RelativeTimeFormat`, `Segmenter`), plural rules in depth, bidi correctness beyond `dir="rtl"`, locale-aware navigation, hreflang and canonical interaction, currency display, numbers in forms.
- `references/pwa-offline.md`: SW lifecycle, offline shell pattern, caching strategies cookbook (cache-first, network-first, stale-while-revalidate, network-only, cache-only), `manifest.json` correctness for installability, install prompt timing, push permission timing, Background Sync, SW kill-switch pattern, Web Share API.
- `references/build-hygiene.md`: tree-shaking at depth (barrel traps, default-export traps, dynamic property access), `sideEffects` declarations and verification, code-splitting strategy, dependency-cost discipline, lockfile hygiene, dead-code elimination, `import()` analysis with bundler-specific notes, source maps for builds, polyfill discipline (browserslist + `core-js` subsets).
- `references/print-email.md`: print stylesheets (`@media print`, `@page`, page-break, `color-adjust: exact`, headers / footers / page numbers, orphans / widows); email HTML constraints (table-based layout, inline CSS via build-time inliner, 600px width, mobile cell-stacking, dark-mode media-query support, Outlook conditional comments + VML, image hosting, tracking-pixel ethics, transactional vs marketing envelope).
- **`references/quick-reference.md`** (extracted from `SKILL.md`): the 41 highest-leverage rules now live in their own reference file. SKILL.md links out instead of inlining the rules block.
- **2024 to 2026 web-platform currency** folded across the existing reference files:
- Performance / Lighthouse: BFCache hygiene, Speculation Rules API, Early Hints (HTTP 103), INP field-attribution recipe via `PerformanceObserver` event + Long Animation Frames, `fetchpriority` beyond images, `<iframe loading="lazy">`, streaming SSR / RSC envelope, ESM-only + import maps + module workers, Compression Dictionaries. New Best Practices rows for Trusted Types, SRI, COOP / COEP / CORP, `Permissions-Policy`; rewritten `valid-source-maps` for private upload; Lighthouse User Flow audits for lab INP; lighthouse-ci CI gate at depth; INP-in-CI via the CrUX / PageSpeed Insights API.
- Motion / UI-UX: native HTML Popover API, `<dialog>` + `inert` proper treatment, CSS Anchor Positioning with progressive-enhancement framing, tooltip dismissal + `aria-describedby` (WCAG 1.4.13), pointer / gesture conflicts on scroll containers (`touch-action`, `overscroll-behavior`), undo / redo systems beyond toast, notification permission timing, `@starting-style` + `transition-behavior: allow-discrete` + `display: none` transitions, cross-document View Transitions, `animation-timeline: scroll()`, Web Animations API, CSS `linear()` easing, motion budget.
- Design / Responsive: OKLCH / OKLab + `color-mix()` for perceptual palettes, P3 wide-gamut, CSS `@layer` for design-system precedence, variable-font axes beyond `wght`, `text-wrap: pretty` perf note, `prefers-reduced-transparency`, brand-tinted dark-mode shadows, `scrollbar-gutter: stable`, `subgrid` (Baseline 2024), `:has()` cookbook, container queries depth (style queries, `@container scroll-state`), `interpolate-size` + `calc-size()` with `height: auto`, foldable / `device-posture`.
- Accessibility: forced-colors mode, WCAG 2.2 new SCs (2.4.11, 2.5.7, 3.2.6, 3.3.7, 3.3.8), live-region timing rules, accessible drag-and-drop pattern, combobox / listbox ARIA APG 1.2 patterns at depth.
- SEO / Forms / Data-viz / Workflow: third-party-cookie deprecation impact, Storage Access API + CHIPS, pagination signals post-`rel=prev/next`, international SEO depth (ccTLD vs subdir vs subdomain, `Content-Language` vs `hreflang`), image and video sitemap extensions, Search Console CrUX mapping, HTML `constraintValidation` API, form analytics, address autocomplete depth, Canvas vs SVG vs WebGL rubric, timezone / DST in time-series, chart annotations, streamed-chart performance, geographic projection rubric.
- Pre-launch / Audit / Components / Defects: security headers gate, privacy / consent gate, error-tracking sanity check, i18n smoke test, SW kill-switch gate, evidence manifest section. Diff-driven re-audit scope, authenticated route capture, side-by-side diff tooling named, audit cadence and ownership. Versioning / breaking-change discipline, slots and composition vs prop explosion, server vs client boundary on the contract, Storybook discipline, token transformation pipeline. New defect rows for iOS Safari `100vh` / rubber-band, z-index stacking-context inside transformed ancestors, `-webkit-autofill` dark-mode mismatch, font-swap CLS metrics-override, `inert` / `aria-hidden` background leaks.
- **Real-project gaps** folded in: third-party script discipline at depth (taming playbook in `performance.md`), list virtualization + infinite scroll (`performance.md`), embed-as-host / embed-as-guest patterns (new `references/embed-patterns.md`), print stylesheets + email HTML (new `references/print-email.md`).
- **`CONTRIBUTING.md`** updated with the YAML frontmatter contract and the corpus-wide keyword glossary (closed task-keyword + symptom vocabulary, synonym clusters). Editing-section pointers refreshed.
### Changed
- **Quick Reference rules moved out of SKILL.md.** The block that lived inline in SKILL.md (41 rules after the prior round) is now `references/quick-reference.md`. SKILL.md links to it instead of inlining; the rest of the corpus cross-references `quick-reference.md` rather than SKILL.md rule numbers.
- **SKILL.md is now a navigation hub.** Loading policy, By Task / By Symptom / Reference-Index-with-keywords routing tables, conflict-resolution table, render-strategy decision tree, freshness note. North Star Targets, Priority Stack, Workflow, Multi-Page Polish Loop index, and Self-Improvement gates are kept; Quick Reference is linked out.
- **Reference Index expanded** from 14 rows to 26 with one-line purposes and comma-separated load-when keywords per file. New rows for security, observability, testing, auth, debug-recipes, anti-patterns, i18n, pwa-offline, build-hygiene, embed-patterns, print-email, quick-reference.
### Migration notes
- **`SKILL.md` Quick Reference rules moved to `references/quick-reference.md`.** Cross-references previously of the form `Rule N in SKILL.md` are rewritten to `Rule N in [quick-reference.md](quick-reference.md)`. If an external consumer linked to a SKILL.md rule anchor, the anchor moved; update links.
- **Frontmatter is now required** on every file under `references/`. Any new reference file added without the YAML block at line 1 will fail `npm run validate` (markdownlint via document-structure rules) and the no-dashes guardrail does not save you. See `CONTRIBUTING.md` "Reference file frontmatter".
- **Keyword glossary is a closed vocabulary.** Adding a new task-keyword or symptom requires updating `CONTRIBUTING.md` "Keyword glossary" in the same change so the corpus stays consistent.
## [0.1.2] - 2026-05-26
### Added
- Three new reference files: `references/audit-workflow.md` (19-phase multi-page audit and polish workflow with capture script and final acceptance gate), `references/components.md` (when to extract, the 11 widget families, component contract checklist, drift detection with collector snippet, CSS patterns that prevent drift), and `references/defects.md` (29-row symptom-to-fix lookup, canonical 9-check programmatic geometry sweep, per-check thresholds).
- Five new "Multi-page consistency" rules in SKILL.md (rules 31 through 35), covering extraction at three instances, no page-local CSS overrides on shared widgets, before-and-after screenshots at canonical viewports, re-render after every fix group on shared widgets, and a clean geometry sweep at `1440x900` and `375x812`.
- New "Multi-Page Polish Loop" section in SKILL.md indexing the 19 phases with deep links into `audit-workflow.md`.
- Six new gates in SKILL.md "Self-Improvement" for multi-page audit work (widget inventory built, baseline and after screenshots at both viewports, geometry sweep clean, drift collector clean, per-route audit table delivered).
- New "Canonical Audit Capture Viewports" subsection in `references/responsive.md` defining `1440x900` (desktop) and `375x812` (mobile) as the standard capture sizes for multi-page polish.
- New "Cross-page consistency" and "Mobile drawer scroll lock" subsections in `references/ui-ux.md`.
- New "Lang, Main, and Skip Link" mini-checklist in `references/accessibility.md`.
- New "Reference Comparison Heuristics" section in `references/design.md` (the 16 visual quality tests).
- New section 17 "Multi-Page Polish Gate" in `references/pre-launch.md` (renumbers the prior 17 to 18).
- New JS-sample policy in `CONTRIBUTING.md` (~30 line cap, standard DOM and CSSOM APIs only, framework-neutral introduction sentence).
- 27 field-tested additions from the frontend-perfection proposal, folded across SKILL.md and 11 reference files (collected from real landing-page optimization work, kept framework-agnostic in the principle-plus-check house voice). Grouped by domain:
- SEO (`references/seo.md`): AI answer-engine readiness (AEO/GEO, an `llms.txt` page index, and a robots.txt note for AI crawler user-agents), render primary content on the server, the FAQ/HowTo SERP-rollback note, templated-title brand-suffix overflow, measuring title length as rendered text (decode entities), BreadcrumbList parity with the visible trail, `og:type` discipline, never fabricate structured data, pairing `noindex` with sitemap exclusion, and build-time per-template Open Graph image generation.
- Lighthouse (`references/lighthouse.md`): CI route-class coverage and the `noindex` SEO-gate trap, CSP versus framework hydration on static hosts (build-time script hashes preferred over `'unsafe-inline'`), and a `duplicate-id` cause pointer.
- Performance (`references/performance.md`): preload by the build-emitted asset URL rather than a hardcoded hash, and serve a high-resolution master only for zoom or lightbox views.
- Components (`references/components.md`): per-instance unique ids in reused components (`useId()`), and DRY applied to data before markup.
- Accessibility, UI/UX, design, responsive: the 44px standalone-control minimum with the WCAG 2.5.8 inline-link exception, modal scroll-lock scrollbar-width compensation, dark-theme accent-text contrast, outside-click plus Escape dismissal, tap-highlight suppression, and a no-JS mobile-nav fallback.
- Defects and workflow (`references/defects.md`, `references/audit-workflow.md`, `references/pre-launch.md`): a programmatic content-and-markup sweep companion to the geometry sweep, a verify-the-shipped-artifact gate, optional parallel-agent audit discipline, and consolidated pre-launch gates.
### Changed
- Quick Reference in SKILL.md grew from 30 to 41 highest-leverage rules. The first batch added 5 "Multi-page consistency" rules; the frontend-perfection batch then added 6 more (AI answer-engine readiness, never fabricate structured data, no-JS mobile nav, unique ids in reused components, scroll-lock scrollbar compensation, verify the shipped artifact) slotted under their domain headings, and refined the touch-target rule to lead with CSS pixels and carry the WCAG 2.5.8 inline-link exception. Section heading and rule numbers updated to match.
- "When to Use" in SKILL.md adds the multi-page audit and polish trigger.
- "Workflow" in SKILL.md frames the rendered browser as the source of truth and points readers to the Multi-Page Polish Loop when the work is auditing or polishing an existing site.
- Subsumed `visual-polish-websites` skill: every practice from that skill (operating rules, 19-phase workflow, widget inventory, screenshot-driven evidence, geometry sweep, defect lookup, reference-level heuristics, final acceptance gate) is now covered in the merged skill, with framework-named extraction examples replaced by a single framework-neutral component contract.
- Repositioned as an Agent Skills package supporting both Claude Code and OpenAI Codex CLI (plus any other harness that follows the open Agent Skills standard). README headline, package.json description, and keywords now lead with cross-harness framing rather than Claude-only branding. No content changes: the SKILL.md prose was already provider-neutral.
- Reordered package.json keywords to lead with `agent-skills`, `agents-md`, `codex`, `claude-code` so registry searches for open-standard terms surface this package first.
- Updated git-submodule install path in README from `.claude/skills/` to `.agents/skills/` to match the canonical install topology used by `@ctxr/kit`.
- Declared `publishConfig.access: "public"` so scoped npm publish does not require a trailing `--access public`.
## [0.1.0] - 2026-05-08
### Added
- Initial release. SKILL.md plus 11 reference docs covering Lighthouse mastery, performance optimisation, accessibility (WCAG 2.2 AA), SEO, UI/UX patterns, design aesthetics, responsive layout, motion and animation, forms and feedback, data visualisation, and a pre-launch verification checklist.
.markdownlint.jsonc
{
"MD013": false,
"MD033": false,
"MD024": { "siblings_only": true },
"MD025": false,
"MD026": false,
"MD029": false,
"MD004": { "style": "dash" },
"MD009": true,
"MD010": true,
"MD022": true,
"MD032": true,
"MD036": false,
"MD034": false,
"MD040": false,
"MD038": false,
"MD060": false,
"globs": ["**/*.md"],
"ignores": ["node_modules/**", ".git/**"]
}
package.json
{
"name": "@ctxr/skill-frontend-excellence",
"version": "0.3.1",
"description": "Agent Skills playbook (Claude Code, Codex CLI): framework-agnostic guide for shipping ultra-fast, accessible, search-friendly, visually distinctive web interfaces. Lighthouse mastery, UI/UX, design aesthetics, performance, accessibility, SEO, motion, forms, layout, data-viz, multi-page audit workflow, component standardization, geometry sweep.",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/ctxr-dev/skill-frontend-excellence.git"
},
"homepage": "https://github.com/ctxr-dev/skill-frontend-excellence#readme",
"bugs": {
"url": "https://github.com/ctxr-dev/skill-frontend-excellence/issues"
},
"keywords": [
"agent-skills",
"agents-md",
"codex",
"claude-code",
"skill",
"frontend",
"lighthouse",
"accessibility",
"seo",
"ui-ux",
"design",
"performance",
"core-web-vitals",
"ai",
"agents",
"ctxr"
],
"files": [
"SKILL.md",
"references/",
"scripts/check-no-dashes.mjs",
"scripts/validate-structure.mjs",
"README.md",
"LICENSE",
"CHANGELOG.md"
],
"ctxr": {
"type": "skill",
"target": "folder"
},
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=20"
},
"scripts": {
"lint": "markdownlint-cli2 \"**/*.md\" \"#node_modules\"",
"lint:fix": "markdownlint-cli2 --fix \"**/*.md\" \"#node_modules\"",
"validate:no-dashes": "node scripts/check-no-dashes.mjs",
"validate:structure": "node scripts/validate-structure.mjs",
"validate:structure:fix": "node scripts/validate-structure.mjs --fix",
"validate": "npm run validate:no-dashes && npm run validate:structure && npm run lint",
"prepare": "husky",
"prepublishOnly": "npm run validate"
},
"devDependencies": {
"husky": "^9.1.0",
"markdownlint-cli2": "^0.14.0"
}
}
package-lock.json
{
"name": "@ctxr/skill-frontend-excellence",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ctxr/skill-frontend-excellence",
"version": "0.3.1",
"license": "MIT",
"devDependencies": {
"husky": "^9.1.0",
"markdownlint-cli2": "^0.14.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
"integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/globby": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/globby/-/globby-14.0.2.tgz",
"integrity": "sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^2.1.0",
"fast-glob": "^3.3.2",
"ignore": "^5.2.4",
"path-type": "^5.0.0",
"slash": "^5.1.0",
"unicorn-magic": "^0.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
"license": "MIT",
"bin": {
"husky": "bin.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsonc-parser": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"dev": true,
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdownlint": {
"version": "0.35.0",
"resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.35.0.tgz",
"integrity": "sha512-wgp8yesWjFBL7bycA3hxwHRdsZGJhjhyP1dSxKVKrza0EPFYtn+mHtkVy6dvP1kGSjovyG5B8yNP6Frj0UFUJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"markdown-it": "14.1.0",
"markdownlint-micromark": "0.1.10"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/DavidAnson"
}
},
"node_modules/markdownlint-cli2": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.14.0.tgz",
"integrity": "sha512-2cqdWy56frU2FTpbuGb83mEWWYuUIYv6xS8RVEoUAuKNw/hXPar2UYGpuzUhlFMngE8Omaz4RBH52MzfRbGshw==",
"dev": true,
"license": "MIT",
"dependencies": {
"globby": "14.0.2",
"js-yaml": "4.1.0",
"jsonc-parser": "3.3.1",
"markdownlint": "0.35.0",
"markdownlint-cli2-formatter-default": "0.0.5",
"micromatch": "4.0.8"
},
"bin": {
"markdownlint-cli2": "markdownlint-cli2.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/DavidAnson"
}
},
"node_modules/markdownlint-cli2-formatter-default": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/markdownlint-cli2-formatter-default/-/markdownlint-cli2-formatter-default-0.0.5.tgz",
"integrity": "sha512-4XKTwQ5m1+Txo2kuQ3Jgpo/KmnG+X90dWt4acufg6HVGadTUG5hzHF/wssp9b5MBYOMCnZ9RMPaU//uHsszF8Q==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/DavidAnson"
},
"peerDependencies": {
"markdownlint-cli2": ">=0.0.4"
}
},
"node_modules/markdownlint-micromark": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/markdownlint-micromark/-/markdownlint-micromark-0.1.10.tgz",
"integrity": "sha512-no5ZfdqAdWGxftCLlySHSgddEjyW4kui4z7amQcGsSKfYC5v/ou+8mIQVyg9KQMeEZLNtz9OPDTj7nnTnoR4FQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/DavidAnson"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"dev": true,
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/path-type": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz",
"integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
"integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"dev": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz",
"integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}
references/anti-patterns.md
---
title: Anti-Patterns Index
purpose: A single index of every pattern the skill names as an anti-pattern, with its standard fix and a deep link. An agent answering "is this an anti-pattern?" lands here, not in a dozen scattered "What to Avoid" sections.
load-when:
task-keywords: [anti-pattern, what to avoid, mistake, audit, polish, accessibility, axe, SRI, errors-in-console]
symptoms: [score dropped, contrast fail, duplicate id, horizontal scroll, focus trap leak, hydration mismatch, errors-in-console, stale SRI beacon]
prereq: SKILL.md
related: [quick-reference.md, defects.md, components.md]
size: ~161 lines
---
# Anti-Patterns Index
## Why this exists
The skill names dozens of anti-patterns across its reference files: a "Common Mistakes" section in `forms.md`, a "What to Avoid" table in `design.md`, a row in `defects.md`, a sentence buried in `performance.md`. An agent asking "is this an anti-pattern?" should not scan twelve files.
This file is the single index. Every row names one pattern, says why it is bad in one phrase, names the standard fix, and links to the deep treatment. It restates nothing; it routes to the file that does.
Search by symptom (the column you scan first) or by category (the section heading). Add a row when a new anti-pattern is named in a deep reference; do not invent rows here that the deep references do not back up.
## How to read a row
- **Pattern.** The shape of the anti-pattern as it shows up in code or markup. Concrete.
- **Why bad.** One phrase. The reason a reviewer would reject it.
- **Standard fix.** The named replacement, in the project's own vocabulary.
- **Deep link.** The reference file that explains the fix at depth.
## Performance
| Pattern | Why bad | Standard fix | Deep link |
|--------|---------|--------------|-----------|
| Hardcoded asset path that breaks on hash change | The next deploy invalidates every preload at once | Preload by the emitted URL, resolved at build time | [performance.md](performance.md) |
| SPA without route-level code-split | Every route ships the whole app | Split by route; lazy-load route bundles | [performance.md](performance.md) |
| Eager hydration of below-the-fold widgets | First-load JS budget pays for things the user never sees | Defer hydration until viewport intersection or interaction | [performance.md](performance.md) |
| LCP image with `loading="lazy"` | Lazy defers the most important paint | `loading="eager"` plus `fetchpriority="high"` on the hero only | [performance.md](performance.md) |
| LCP image with no declared dimensions | CLS budget burns when the image arrives | Declare `width` and `height` attributes; `aspect-ratio` on the container | [performance.md](performance.md) |
| Preloading four font weights | Preload steals bandwidth from the LCP image | Preload at most one critical weight; `font-display: swap` for the rest | [performance.md](performance.md) |
| `font-display: block` on body text | Invisible text until the webfont arrives | `swap` for body, `optional` for display fonts | [performance.md](performance.md) |
| Animation that touches `width`, `height`, `top`, `left`, `margin` | Triggers layout on every frame | Animate `transform` and `opacity` only | [performance.md](performance.md), [motion.md](motion.md) |
| Third-party script loaded synchronously in `<head>` | Blocks parsing; budget hostage | `async` or `defer`, `fetchpriority="low"`, audit count monthly | [performance.md](performance.md) |
| `@import` chains in CSS | Serializes requests; blocks render | Concatenate at build time; one `<link>` per critical sheet | [performance.md](performance.md) |
| Cache-busting URL on a never-changing asset | Wastes bandwidth on every reload | Content-hash the URL; serve `Cache-Control: immutable` for a year | [performance.md](performance.md) |
| Render a long list without virtualization | Main thread chokes on 10000 nodes | Virtualize with intersection observers or a windowed renderer | [performance.md](performance.md) |
| `setTimeout` in a render path to defer expensive work | Hides the cost; INP regresses | Schedule via `scheduler.yield` or move to a worker | [performance.md](performance.md) |
| CSS-delivery change shipped without a full-site axe re-run | Inlining, bundling, or reordering can flip a computed color via cascade source order with no authored-rule change | After any css-delivery change, re-run axe on every route and eyeball both audit viewports | [lighthouse.md](lighthouse.md), [testing.md](testing.md) |
| Third-party beacon auto-injected with a pinned SRI integrity hash plus crossorigin | When the vendor rolls the asset forward the pinned hash goes stale, the CORS-mode fetch fails, and errors-in-console fires for every visitor | Self-inject the plain vendor tag (no integrity, no crossorigin, no pinned version) so it loads no-cors; edge auto-injectors skip injection when the tag is already present, so the plain tag pre-empts the broken pinned one | [debug-recipes.md](debug-recipes.md), [lighthouse.md](lighthouse.md) |
## Accessibility
| Pattern | Why bad | Standard fix | Deep link |
|--------|---------|--------------|-----------|
| Placeholder used as label | Placeholders disappear on input; SR support varies | Visible `<label for>` (or `aria-labelledby`); placeholder is a hint only | [accessibility.md](accessibility.md), [forms.md](forms.md) |
| `<div onclick>` as button | No role, no keyboard, no focus, no SR semantics | `<button type="button">` | [accessibility.md](accessibility.md) |
| `<span onclick>` for navigation | Same, plus no URL semantics | `<a href>` | [accessibility.md](accessibility.md) |
| `outline: none` without a replacement focus style | Keyboard users cannot see where they are | `:focus-visible` ring, 3:1 contrast against surface and resting state | [accessibility.md](accessibility.md) |
| Hardcoded DOM `id` in a reused component | Duplicate ids when the component renders twice | Per-instance id source (the framework's `useId` equivalent); namespace child ids | [components.md](components.md), [quick-reference.md](quick-reference.md) |
| `aria-hidden="true"` on a focusable element | Focus lands on a node the SR cannot announce | Remove from tab order with `inert` (or `tabindex="-1"` plus the rest of the inert pattern) | [accessibility.md](accessibility.md) |
| `tabindex` greater than zero | Overrides natural order; surprises every keyboard user | Use `tabindex="0"` or `tabindex="-1"` only; rely on document order | [accessibility.md](accessibility.md) |
| Skip link hidden with `position: absolute; left: -9999px` | Some browsers still include it in the layout box | `clip-path: inset(100%)` when hidden; reveal on `:focus` | [accessibility.md](accessibility.md), [defects.md](defects.md) |
| Heading levels skipped (H1 then H3) | Breaks document outline for SR users | Sequential descent; one H1 per page | [accessibility.md](accessibility.md) |
| `<button>` inside `<a>` (or vice versa) | Invalid content model; ambiguous activation | Pick one; the activation surface is the parent | [accessibility.md](accessibility.md) |
| Color as the only signal (red border for error, no icon, no text) | Colorblind users see no error | Pair color with icon and text on every status | [accessibility.md](accessibility.md), [design.md](design.md) |
| Live-region element added to the DOM at the moment of update | Some SRs do not announce; the region must be present at load | Render the live region empty at load; mutate its content on update | [accessibility.md](accessibility.md) |
| `<button>` with no accessible name (icon only, no `aria-label`) | SR announces "button" with no purpose | `aria-label` or visually-hidden text inside the button | [accessibility.md](accessibility.md) |
| Inline svg given `role="img"` with no accessible name | Exposes a nameless image to assistive tech and trips image-alt, dropping Accessibility and SEO | Decorative svg: `aria-hidden="true"`. Meaningful svg: `role="img"` plus a `title` or `aria-label` | [accessibility.md](accessibility.md) |
| Modal that traps Tab but not the screen reader | Background remains in the AT tree | Mark every sibling of the dialog `inert` while open | [accessibility.md](accessibility.md), [debug-recipes.md](debug-recipes.md) |
## SEO
| Pattern | Why bad | Standard fix | Deep link |
|--------|---------|--------------|-----------|
| Client-only content invisible to crawlers | The crawler sees a skeleton; nothing indexes | SSR, SSG, or streaming HTML that includes the content | [seo.md](seo.md) |
| Fabricated structured data (claims a review or rating that does not exist) | Search-engine manual action; removed from rich results | Only mark up content the user actually sees | [seo.md](seo.md) |
| `noindex` page still listed in `sitemap.xml` | Wastes crawl budget; mixed signals | Remove from the sitemap; the sitemap is the indexable index | [seo.md](seo.md) |
| Canonical that points to a different page than the URL the user lands on | The canonical chain is inconsistent; ranking signals leak | Canonical points at the URL the user reached, or the chosen one in a duplicate set | [seo.md](seo.md) |
| Title tag duplicated across the site | Search engines cannot tell the pages apart | One unique title per page, 50 to 60 characters | [seo.md](seo.md), [defects.md](defects.md) |
| Meta description missing on indexable pages | Search engines generate one; loses control of the snippet | One unique description per indexable page, 140 to 160 characters | [seo.md](seo.md) |
| Multiple H1 elements on a page | Outline is ambiguous; ranking signal weakens | One H1; subsequent sections use H2 | [seo.md](seo.md), [accessibility.md](accessibility.md) |
| Image with no `alt` attribute | Image cannot be indexed; SR cannot announce | `alt=""` for decorative; meaningful `alt` for content | [accessibility.md](accessibility.md), [seo.md](seo.md) |
| `hreflang` set on one locale but not its return reference | Search engines drop the cluster; no locale routing | Bidirectional `hreflang`; every locale references every other plus `x-default` | [seo.md](seo.md), [i18n.md](i18n.md) |
| robots.txt blocks JS or CSS the page needs to render | Crawler renders a broken page; ranking drops | Allow JS and CSS routes; only block sensitive paths | [seo.md](seo.md) |
## UI / UX
| Pattern | Why bad | Standard fix | Deep link |
|--------|---------|--------------|-----------|
| Scroll-lock without scrollbar compensation | Page shifts sideways when the modal opens | Set `body { overflow: hidden }` plus `padding-right` equal to scrollbar width | [ui-ux.md](ui-ux.md), [defects.md](defects.md) |
| Hover-only menu on touch | First tap opens, second tap navigates; users get stuck | Show on hover, focus, and tap; or use a press-to-open menu | [ui-ux.md](ui-ux.md) |
| Modal with no Esc handler | Keyboard users have no way out | Esc closes; focus restored to the trigger | [ui-ux.md](ui-ux.md), [accessibility.md](accessibility.md) |
| Custom dropdown that does not support arrow keys | Keyboard users cannot select | Implement the ARIA combobox or listbox pattern in full | [ui-ux.md](ui-ux.md), [accessibility.md](accessibility.md) |
| Tooltip that only opens on hover | Touch and keyboard users cannot read it | Open on hover, focus, and tap; close on Esc and outside | [ui-ux.md](ui-ux.md) |
| Toast that disappears too fast to read | Users miss critical confirmation | Minimum 5 seconds; allow pause on hover; persist destructive confirmations | [ui-ux.md](ui-ux.md) |
| Empty state that is genuinely blank (no message, no action) | User cannot tell whether the page is broken | Specific message, the condition that fills it, the action that resolves it | [ui-ux.md](ui-ux.md), [defects.md](defects.md) |
| Loading state that is a generic spinner with no context | User cannot tell whether the system is doing the right thing | Skeleton matched to the content; or a labelled progress indicator | [ui-ux.md](ui-ux.md) |
| Touch target under 44 by 44 CSS pixels | Fat-fingered users miss; WCAG 2.5.5 fail | 44 by 44 minimum; for inline links, the WCAG 2.5.8 inline exception | [ui-ux.md](ui-ux.md), [defects.md](defects.md) |
| Confirm dialog that uses "OK" and "Cancel" | Users do not know which is destructive | Verb labels on both buttons ("Delete account" and "Keep account") | [ui-ux.md](ui-ux.md) |
## Design
| Pattern | Why bad | Standard fix | Deep link |
|--------|---------|--------------|-----------|
| A geometric grotesque sans on white with a purple gradient | The default AI aesthetic; instantly recognised as generic | Pick a typeface with character (a serif, a humanist sans, a variable display); pair with a restrained palette | [design.md](design.md) |
| Raw hex values in component CSS | Brand palette drifts per component; dark mode breaks | Reference design tokens; tokens are the single source | [design.md](design.md), [components.md](components.md) |
| Brand accent that fails contrast in dark mode | Brand recognition costs accessibility | Compute the dark-mode brand variant against the dark surface; aim for 4.5:1 on text, 3:1 on UI | [design.md](design.md), [accessibility.md](accessibility.md) |
| Inconsistent corner radii (3, 4, 5, 6 px in the same view) | Visual noise; eye cannot anchor | Define a token scale (4, 8, 12, 16) and use only the scale | [design.md](design.md) |
| One typographic scale for marketing, another for product | Brand fractures at the seam | Single type scale across surfaces; loosen line-height and tracking for marketing only | [design.md](design.md) |
| Pure black (`#000`) on pure white (`#fff`) | Painful contrast; designers know to soften | Slightly off-black on off-white; preserve the contrast ratio | [design.md](design.md) |
| Icon set drawn at three different stroke widths | Visual incoherence | One icon family; tune stroke width once | [design.md](design.md), [components.md](components.md) |
| Decorative shadow at the same depth as elevation tokens | Shadows lose semantic meaning | Reserve elevation shadows for elevation; decorative blur is a separate token | [design.md](design.md) |
| Animated gradient background at 60fps in the hero | Main thread busy; the page never feels finished | Static or low-frame-rate gradient; reserve motion for affordance | [design.md](design.md), [motion.md](motion.md) |
## Components
| Pattern | Why bad | Standard fix | Deep link |
|--------|---------|--------------|-----------|
| Hardcoded DOM `id` in a reused component | Duplicate ids; broken ARIA references | Per-instance id source; namespace child ids | [components.md](components.md), [quick-reference.md](quick-reference.md) |
| Near-duplicate components with different names but the same purpose | The library fragments; users get different behaviour on different pages | Merge into one component with named variants | [components.md](components.md) |
| Prop explosion (a single component with twenty boolean props) | Combinatorial states; tests cannot cover; readers cannot reason | Slots and composition; pull the variants apart into smaller components | [components.md](components.md) |
| Inline styles in a shared component | Bypasses tokens; impossible to theme | All styles via tokens or themed classes; inline only for computed measurements | [components.md](components.md), [design.md](design.md) |
| Component reaches outside its DOM for data (`document.querySelector` of a sibling) | Coupling to page structure; the component breaks on reuse | Pass dependencies as props; expose events for the parent to react | [components.md](components.md) |
| Page-local CSS overriding a shared component | The override travels per page; the contract leaks | Add the variant to the component; no page-local overrides | [components.md](components.md) |
| Component that does not document its server vs client boundary | Renders break across surfaces; bugs land in production | Document the boundary on the contract; mark the file or function explicitly | [components.md](components.md) |
| Shared component with no isolated playground entry | No way to reproduce in isolation; bug reports route to the page that hosts it | Every shared component has a playground entry per variant | [components.md](components.md) |
## Forms
| Pattern | Why bad | Standard fix | Deep link |
|--------|---------|--------------|-----------|
| `<input type="text">` for an email | No mobile keyboard, no built-in validation | `<input type="email">` plus `autocomplete="email"` | [forms.md](forms.md), [accessibility.md](accessibility.md) |
| No `autocomplete` on a sign-up form | Password managers and autofill do not work | Every field has the standard `autocomplete` value | [forms.md](forms.md) |
| Validation that fires on every keystroke | Errors flash before the user finishes typing | Validate on blur for new errors; clear errors live as the user types | [forms.md](forms.md), [accessibility.md](accessibility.md) |
| Error message that only says "Invalid input" | User cannot fix what they cannot diagnose | Cause AND fix in one sentence ("Enter your work email") | [forms.md](forms.md) |
| Submit button disabled until the form is valid | Users do not learn what is wrong | Always submittable; show errors on submit; focus the first invalid field | [forms.md](forms.md) |
| Custom phone-number field that strips spaces | Pastes from contacts fail; users retype | Accept any reasonable format; normalise on the server | [forms.md](forms.md) |
## Motion
| Pattern | Why bad | Standard fix | Deep link |
|--------|---------|--------------|-----------|
| Ignoring `prefers-reduced-motion` | Vestibular users get sick | Wrap non-essential motion in the media query; provide a static alternative | [motion.md](motion.md), [accessibility.md](accessibility.md) |
| Auto-playing video in the hero with no pause control | Motion plus audio with no opt-out; WCAG 2.2.2 fail | Provide pause and mute controls; respect reduced-motion | [motion.md](motion.md) |
| Endless animation in the periphery | Distracts every user; never returns to a rest state | Cap to a few cycles, or trigger on intent (hover, scroll into view) | [motion.md](motion.md) |
| Animating `box-shadow` on hover | Repaints the whole element every frame | Pre-render the shadow; cross-fade two layers via `opacity` | [motion.md](motion.md), [performance.md](performance.md) |
## Defects
| Pattern | Why bad | Standard fix | Deep link |
|--------|---------|--------------|-----------|
| Body scroll lock without `padding-right` compensation | Page jumps sideways when the modal opens | Measure scrollbar width; set `body { padding-right: <gap> }` while locked | [defects.md](defects.md), [ui-ux.md](ui-ux.md) |
| `width: 100vw` inside a padded container | Element extends past the viewport on mobile | `width: 100%` (which respects the parent) | [responsive.md](responsive.md), [defects.md](defects.md) |
| Unstyled `<iframe>` or `<video>` | Default intrinsic width overflows | `max-width: 100%; width: 100%` | [defects.md](defects.md), [responsive.md](responsive.md) |
| Table without `table-layout: fixed` on mobile | One long cell stretches the table past the viewport | `table-layout: fixed; width: 100%; word-break: break-word`; or transform to cards | [defects.md](defects.md), [responsive.md](responsive.md) |
| Focus ring clipped by `overflow: hidden` | Keyboard users cannot see focus | Move the ring to `outline` (paints outside) or add internal padding | [accessibility.md](accessibility.md), [defects.md](defects.md) |
| Webfont flash on first paint | Visible swap that shifts layout | `font-display: swap` plus metrics-override fallback | [debug-recipes.md](debug-recipes.md), [performance.md](performance.md) |
## See also
- [quick-reference.md](quick-reference.md) for the rules these anti-patterns negate
- [defects.md](defects.md) for the symptom-to-fix lookup that pairs with this index
- [components.md](components.md) for the component-contract patterns whose violations dominate this index
references/auth.md
---
title: Authentication Flows
purpose: Flow-level authentication patterns: passkeys and WebAuthn, OAuth redirect UX, magic links, session expiry, account recovery, CAPTCHA placement, sign-in and sign-up structure, cross-tab session sync, and Storage Access API for embedded sign-in. Input-level concerns (label, validation, autofill) live in forms.md.
load-when:
task-keywords: [auth, authentication, login, signup, passkey, WebAuthn, OAuth, magic link, session, account recovery, CAPTCHA, Storage Access API]
symptoms: [auth redirect loop, passkey not offered, focus not visible, focus trap leak]
prereq: SKILL.md
related: [forms.md, embed-patterns.md, security.md, accessibility.md]
size: ~367 lines
---
# Authentication Flows
Framework-agnostic patterns for authentication FLOWS: redirects, sessions, recovery, second factors, embedded sign-in. Input-level concerns (label, `autocomplete`, `aria-invalid`, error placement, multi-error focus return, password-manager detection) live in `forms.md`. These two age at different speeds: edit `forms.md` for label markup, edit this file for what happens after submit. Cross-link, do not duplicate.
## Passkeys and WebAuthn
Passkeys are the modern default for new sign-in flows: no phishing surface, no password-reset funnel for enrolled users.
| Principle | Check (with threshold/value) |
| --- | --- |
| Offer passkeys inline via conditional UI above the username field, not as a sibling "Sign in with passkey" button next to "Sign in with password" | With a registered passkey present, the password-manager popup offers the passkey at the username field, not after submit |
| `autocomplete="username webauthn"` on the username input | The `webauthn` token is the signal; without it the browser will not offer the passkey at this field |
| `mediation: 'conditional'` makes `navigator.credentials.get` non-modal (surfaced via autofill popup) | A normal `get` without conditional mediation pops a modal, the wrong sign-in UX |
| Trigger `navigator.credentials.create` only after the user is authenticated by another method, from a deliberate "Set up a passkey" affordance in account settings | Never silently on first sign-in |
| After a password sign-in, show a one-time inline prompt (not a modal) to enrol a passkey | Dismissing is one click; accepting triggers `create` |
| Support floor: `navigator.credentials.get` with `mediation: 'conditional'` runs in current Chromium, Safari 16+, Firefox 122+ | Feature-detect via `isConditionalMediationAvailable` and fall back to the password field without layout shift |
Conditional UI sign-in form markup:
```html
<form id="signin">
<label for="username">Email</label>
<input
id="username"
name="username"
type="email"
autocomplete="username webauthn"
required
/>
<label for="current-password">Password</label>
<input
id="current-password"
name="password"
type="password"
autocomplete="current-password"
/>
<button type="submit">Sign in</button>
</form>
```
The conditional `get` call:
```js
async function startConditionalPasskey() {
if (!('credentials' in navigator)) return;
if (!PublicKeyCredential.isConditionalMediationAvailable) return;
const available = await PublicKeyCredential.isConditionalMediationAvailable();
if (!available) return;
const options = await fetch('/auth/passkey/challenge').then((r) => r.json());
try {
const cred = await navigator.credentials.get({
publicKey: options,
mediation: 'conditional',
});
await fetch('/auth/passkey/verify', {
method: 'POST',
body: JSON.stringify(cred),
credentials: 'include',
});
location.assign('/');
} catch (err) {
// user dismissed the autofill popup, fall through to password
}
}
document.addEventListener('DOMContentLoaded', startConditionalPasskey);
```
## OAuth Redirect UX
Three pages: your sign-in page, the provider's consent screen, your callback page. Flow rules govern what happens between them.
| Principle | Check (with threshold/value) |
| --- | --- |
| Authorization Code with PKCE only | Implicit flow and password grant are deprecated |
| PKCE on the wire | Redirect URL contains `code_challenge` and `code_challenge_method=S256`; callback exchange POST includes `code_verifier`; no `response_type=token` anywhere |
| `state` is required, cryptographically random per redirect | Store in `sessionStorage` (not `localStorage`), verify on callback, cancel with an error on mismatch |
| One callback path per provider, registered exactly | No wildcards |
| Callback is a plain page in the trust boundary of your auth | No third-party scripts, no analytics, no advertising tags |
| Callback exchanges the code, clears `state` and verifier from `sessionStorage`, redirects onward | Callback URL is on the allow-list, returns only first-party assets, fires analytics only after the session is established |
The PKCE redirect:
```js
function startOAuth(provider) {
const state = crypto.randomUUID();
const verifier = generateVerifier(64);
sessionStorage.setItem('oauth.state', state);
sessionStorage.setItem('oauth.verifier', verifier);
const url = new URL(`/oauth/${provider}/authorize`, ORIGIN);
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', sha256base64url(verifier));
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('redirect_uri', `${ORIGIN}/oauth/callback`);
location.assign(url);
}
```
Provider error states (returned on the callback query string). Each renders a distinct, plain-language message; none surface raw provider strings.
| `error` value | Frontend response |
| --- | --- |
| `access_denied` | "Sign-in cancelled. Try again or use another method." |
| `consent_required` | Redirect back to authorize with `prompt=consent` |
| `login_required` | Redirect back to authorize with `prompt=login` |
| `invalid_scope` | Engineering bug: log and show a generic error |
| `server_error`, `temporarily_unavailable` | "Provider is having issues. Try again in a minute." |
Token storage:
| Principle | Check (with threshold/value) |
| --- | --- |
| Never store access tokens or refresh tokens in `localStorage` | Cross-site scripting reads everything in `localStorage` |
| Session lives in a backend-set `httpOnly`, `Secure`, `SameSite=Lax` cookie (or `Strict` where the flow allows) | See `security.md` for cookie hardening |
| If the frontend must hold a token, keep it in memory in a closure for the page session only | Drop it on reload |
| Verify in DevTools Application tab after sign-in | `localStorage` and `sessionStorage` hold no token/JWT/refresh token; session cookie is `httpOnly` with `Secure` set |
## Magic Link UX
Magic links remove the password from the sign-in surface: low-friction sign-up, infrequent sign-in, clean fallback when a passkey is lost.
| Principle | Check (with threshold/value) |
| --- | --- |
| Magic link expires under 15 minutes from issuance | Longer is a credential |
| Single use | Invalidates on first successful exchange and on a second attempt |
| Link redeems a short-lived session token | It does not itself become the session |
| Second redemption rejects clearly | "Link already used" message that offers to send a new one |
| Landing page presents two affordances | "Continue on this device" (default) and "Use on another device" (handoff) |
| Handoff shows a six- or eight-digit numeric code and a QR code | Waiting tab on the original device polls a short endpoint and unlocks when the code is consumed |
| Link redeems a session on the device that initiated, not the device that clicked | Must not silently sign in on a new device; the cross-device flow is the explicit consent path |
## Session Expiry Handling
Frontends fail at expiry more than at sign-in: an idle tab clicks, gets a silent 401 or a redirect loop.
| Principle | Check (with threshold/value) |
| --- | --- |
| Single fetch interceptor: on 401, attempt one refresh, then retry the original request or escalate to sign-in | Throttle network to Offline, request, return online, retry: no redirect loop, no two stacked refresh calls |
| Refresh token rotates on every use; reuse revokes the family as a stolen-credential signal | Backend behavior; frontend keeps rotation atomic |
| Single-flight refresh via an in-flight guard reset in `finally` | Ten concurrent requests against an expired session result in exactly one `/auth/refresh` call while the other nine wait |
| Soft expiry: client-side inactivity timer triggers at 75 percent of the session window | Non-blocking banner "You have been idle. Sign back in to continue." offering one-click re-auth in place (passkey conditional UI in the modal) without losing form input |
| Hard expiry: server returns 401, full redirect to sign-in with a `next` param back to the original page | Both soft and hard must exist; hard-only causes lost-draft complaints |
401 interception:
```js
async function fetchWithAuth(input, init = {}) {
const res = await fetch(input, { ...init, credentials: 'include' });
if (res.status !== 401) return res;
const refreshed = await refreshSession();
if (!refreshed) {
location.assign(`/signin?next=${encodeURIComponent(location.pathname)}`);
return res;
}
return fetch(input, { ...init, credentials: 'include' });
}
```
Single-flight refresh:
```js
let refreshInFlight = null;
function refreshSession() {
if (!refreshInFlight) {
refreshInFlight = fetch('/auth/refresh', {
method: 'POST',
credentials: 'include',
}).then((r) => r.ok).finally(() => { refreshInFlight = null; });
}
return refreshInFlight;
}
```
## Account Recovery Beyond Password Reset
Password reset is the easy case. The hard cases: lost passkey, lost second factor, lost email, lost phone.
| Principle | Check (with threshold/value) |
| --- | --- |
| At second-factor enrolment, issue ten one-time recovery codes, each single-use, each replacing the second factor for one sign-in | Enrolment shows ten codes with "Copy all", "Print", "Save as .txt" affordances; the flow blocks until the user confirms "I have saved my codes" |
| "Trust this device for 30 days" survives in a long-lived first-party cookie; trusted device skips the second factor, any other device requires it | Device A trusted within 30 days needs no second factor; device B prompts |
| All factors lost: one-step-at-a-time identity verification (email confirmation, identity document where warranted), human review window stated up front ("Most reviews complete in 24 hours") | A user with no remaining factors learns within one screen what they need and how long it takes, with no buried links |
| Single visible status page the user can return to ("Your recovery request is in review since Monday at 2:14 PM") | SLA shown in the UI, not the help center |
| Lost second factor: offer recovery codes first | |
| Recovery codes also gone: email verification plus a 24-to-72-hour holding period before reset | For "lost phone, no recovery codes" the UI explains the holding period, sends a confirmation email, and states exactly when the reset completes |
## CAPTCHA Placement and Accessibility
CAPTCHA stops automated abuse, not legitimate users. Default posture: invisible challenge first, visible challenge only on suspicion.
| Principle | Check (with threshold/value) |
| --- | --- |
| Invisible first, visible only on suspicion (failed attempts from an IP, known-bad fingerprint, anomalous device) | A fresh sign-in page renders without a CAPTCHA; it appears only after a failed attempt or a server-flagged risk score |
| When shown, CAPTCHA goes before the submit button in focus order | Tab order is username, password, CAPTCHA, submit, with no backward jumps or focus traps |
| Surface invisible-CAPTCHA failure as the same inline error pattern any other validation uses (see `forms.md`) | Blocked third-party domain produces an inline error within two seconds, not a stuck spinner |
| Prefer providers (Turnstile, hCaptcha) that run zero-interaction for trusted browsers via Privacy Pass / Private Access Tokens | In a clean profile the CAPTCHA resolves with no visible puzzle for 95 percent of attempts; the 5 percent that escalate present an accessible audio fallback and a visible refresh affordance |
Accessibility floor for the widget:
- Visible focus ring on the widget container.
- Keyboard-operable challenge (Space, Enter, arrow keys).
- Audio fallback for visual puzzles.
- `aria-describedby` on the submit button pointing at the CAPTCHA status text while a challenge is in progress.
- Both keyboard-only and screen-reader-only (VoiceOver / NVDA) users can complete the flow (see `accessibility.md`).
## Sign-In Form Structure
The highest-converting form in most products. Non-negotiable structure.
| Principle | Check (with threshold/value) |
| --- | --- |
| One `<form>`, one submit button, `autofocus` on the username field for the sign-in route only, no off-form floating fields | Cursor lands in the username field; pressing Enter from either field submits |
| Social sign-in above the form (first in tab order), recovery links below (last); the form holds only fields plus submit and recovery links | Tab order from page load: "Skip to content", social options, username, password, submit, "Forgot password", "Create account", with no surprises |
| Multi-error submit: error summary at the top of the form, focused, each item linked to its field (pattern in `forms.md`) | Pressing Enter on the first summary link moves focus to the first invalid field |
| Single-error submit: skip the summary, move focus directly to the invalid field | Inline error renders below the field |
## Sign-Up Progressive Disclosure
The wall-of-fields form is dead. Reveal one or two fields at a time and ask only for the next step.
| Principle | Check (with threshold/value) |
| --- | --- |
| Step 1 collects only email with a "Continue" button; server returns new vs existing; form transitions to step 2 (password) in place, no route change | Entering a new email and clicking Continue reveals a password field with a strength meter without navigation; the browser back button returns to the email step |
| Terms-of-service consent is a required single checkbox directly above submit, agreement text visible; avoid "by clicking submit you agree" | Submitting without the checkbox shows an inline error pointing at the checkbox, not a generic "agree to terms" error |
| Strength meter maps to actual requirements (length, character classes, common-password check); no moving emoji, animated fire icons, or congratulatory copy | Three to five bars across, monochrome with a single accent color, status text underneath ("Strong enough", "Add a longer word", "Avoid common passwords") |
| Strength check runs client-side against a small bloom filter of common passwords AND server-side on submit | Typing "password123" makes the meter immediately flag it as common, with no animation |
Terms consent markup:
```html
<label>
<input type="checkbox" required name="terms" />
I agree to the Terms of Service and Privacy Policy.
</label>
<button type="submit">Create account</button>
```
## Cross-Device and Cross-Tab Session Sync
Users open three tabs and sign in on one. The others need to know.
| Principle | Check (with threshold/value) |
| --- | --- |
| `BroadcastChannel('auth')` posts `signed-in` and `signed-out` so other tabs reload or redirect | Signing in on tab A reloads tab B to signed-in within a second; signing out on tab A redirects tab B to sign-in |
| Routes with in-progress state: non-disruptive banner "You signed in on another tab. Refresh to continue." instead of auto-reload | The draft survives the reload via the draft-recovery pattern (see `forms.md`) |
| Fall back to the `storage` event on the sentinel key `auth.signal` where `BroadcastChannel` is unavailable (some WebViews) | The storage-event path produces the same cross-tab behavior |
BroadcastChannel signal:
```js
const auth = new BroadcastChannel('auth');
function signalSignedIn(user) {
auth.postMessage({ type: 'signed-in', user });
}
function signalSignedOut() {
auth.postMessage({ type: 'signed-out' });
}
auth.onmessage = (event) => {
if (event.data.type === 'signed-in') {
location.reload();
} else if (event.data.type === 'signed-out') {
location.assign('/signin');
}
};
```
Storage-event fallback:
```js
window.addEventListener('storage', (e) => {
if (e.key === 'auth.signal' && e.newValue) {
const msg = JSON.parse(e.newValue);
if (msg.type === 'signed-out') location.assign('/signin');
}
});
function signalSignedOut() {
localStorage.setItem('auth.signal', JSON.stringify({ type: 'signed-out', t: Date.now() }));
}
```
## Storage Access API for Embedded Sign-In
Third-party cookies are deprecated. An embedded sign-in widget (your auth in an iframe on a partner site) must request storage access on a user gesture.
| Principle | Check (with threshold/value) |
| --- | --- |
| Ceremony: render a "Sign in" click target; on click call `document.requestStorageAccess()` before running the sign-in flow so the iframe gets first-party storage on grant | The button is visible immediately on load; `requestStorageAccess` is not in the network or call stack until the user clicks |
| Never call `requestStorageAccess` outside a click handler | The browser denies it without a user gesture; render the button first, put the call inside the click handler |
| Long-lived embedded sessions without top-level access use partitioned cookies (CHIPS) carrying `Partitioned`, scoped per top-level site, surviving reloads on the same host without third-party scope | In DevTools the session cookie shows `Partitioned: true` and a `Partition Key` matching the host site |
| Denied storage access: open sign-in in a new top-level window with `window.open`, complete the flow, post the result back via `postMessage` (see `embed-patterns.md`) | The widget transitions to a "Sign in in a new window" affordance whose pop-out, on close, returns the embed to signed-in |
Request-storage-access ceremony:
```js
button.addEventListener('click', async () => {
try {
if ('requestStorageAccess' in document) {
await document.requestStorageAccess();
}
await runSignInFlow();
} catch (err) {
showFallback();
}
});
```
## Anti-Patterns
- Storing access tokens or refresh tokens in `localStorage`: cross-site scripting reads them.
- Implicit-flow OAuth: the token leaks in the URL fragment.
- A "Sign in with passkey" button as a sibling to "Sign in with password": bury the password, surface the passkey inline.
- Magic links that expire after 24 hours: that is a credential.
- Silent redirect loops on 401: always escalate to sign-in with a `next` param.
- CAPTCHA on the first interaction: add only on suspicion.
- "By signing in you agree to terms": make consent explicit and visible.
- Strength meters that animate: theatre that wastes the user's time.
- Cross-tab sign-in that auto-reloads a tab with unsaved input: show a banner, let the user choose.
- Embedded sign-in that calls `requestStorageAccess` on page load: the browser denies it and the UX dies.
## Self-Healing Checklist
- [ ] Passkey conditional UI offered above the username field, not as a separate flow
- [ ] `autocomplete="username webauthn"` on the username input
- [ ] OAuth uses Authorization Code with PKCE; no implicit flow anywhere
- [ ] OAuth `state` and `code_verifier` in `sessionStorage`, cleared on callback
- [ ] Tokens never in `localStorage`; session in `httpOnly`, `Secure` cookies
- [ ] Magic link expires under 15 minutes and is single-use
- [ ] Magic link supports cross-device handoff (numeric code or QR)
- [ ] 401 interception with single-flight refresh, no redirect loops
- [ ] Soft-expiry banner before hard expiry, no lost work
- [ ] Recovery codes issued at second-factor enrolment, ten codes, copy / print / save affordances
- [ ] Trusted-device option present and respected
- [ ] Lost-second-factor path includes a documented holding period (24 to 72 hours)
- [ ] CAPTCHA invisible by default, visible only on suspicion, before submit in tab order
- [ ] CAPTCHA failure surfaces as an inline error within two seconds
- [ ] Sign-in form: single submit, autofocus the first field, no Tab traps
- [ ] Multi-error submit shows a top summary, focuses the first invalid field via summary link
- [ ] Sign-up progressively discloses (email, then password); browser back works
- [ ] Terms consent is an explicit checkbox above submit
- [ ] Password strength meter is functional, not theatrical
- [ ] `BroadcastChannel` cross-tab signal in place, with `storage` event fallback
- [ ] Storage Access API requested only inside a user click handler
- [ ] CHIPS partitioned cookies for embedded session, or pop-out fallback if denied
## See Also
- [forms.md](forms.md): input-level concerns (label, validation, autofill, error placement, draft recovery)
- [embed-patterns.md](embed-patterns.md): postMessage handshake, pop-out window, viewport reporting
- [security.md](security.md): cookie hardening, CSP, COOP, COEP, CORP, Trusted Types, Permissions-Policy
- [accessibility.md](accessibility.md): focus management, ARIA, live regions, screen-reader flows
references/audit-workflow.md
---
title: Multi-Page Audit and Polish Workflow
purpose: Procedural playbook for auditing an existing multi-page site against a reference and polishing every route to one consistent visual bar. The rendered browser is the source of truth.
load-when:
task-keywords: [audit, route, sweep, screenshot, baseline, capture, polish, drift, widget, component, regression]
symptoms: [score dropped, viewport overflow, horizontal scroll, broken on Safari, broken on Firefox, dark mode broken]
prereq: SKILL.md
related: [components.md, defects.md, pre-launch.md, design.md]
size: ~444 lines
---
# Multi-Page Audit and Polish Workflow
Use this when the work is not building a new page but bringing an existing surface to a consistent visual bar across every route. The greenfield frame (Frame, Plan, Build, Verify in SKILL.md) is for new work; the 19 phases below are for audit and polish. Run them in order; each phase has a clear input and a clear output.
Vague goal restatement: when a request is a vague quality goal ("best SEO", "make it 100 everywhere"), restate it as the explicit per-category, per-route numeric bars and the measurement method BEFORE acting.
## Core Principle
- Treat the rendered browser as the source of truth. Do not judge visual quality from code alone. Capture, inspect, compare against reference, patch the smallest set of templates/styles/assets, re-capture until clean at every requested viewport.
- Goal is to match the reference site's level of discipline (proportion, density, hierarchy, alignment, typography, spacing, interaction, responsive refinement, absence of defects), not copy another brand pixel-for-pixel unless explicitly requested.
- Repeated widgets (hero, feature card, pricing card, integration tile, CTA band, testimonial, FAQ, footer column, media row, badge, form, nav dropdown) must follow one canonical visual and content contract unless there is a deliberate documented variant. Polishing each instance separately is not enough.
## Operating Rules
Apply to every phase below. Treat any violation as a defect.
- Identify the target URL, project root, build system, source directories, and reference URL or screenshots before making changes.
- Capture a baseline before editing when visual polish is the task.
- Capture at least one desktop and one mobile viewport, using `1440x900` and `375x812` unless the user specifies otherwise. Breakpoints table: see responsive.md.
- Audit route-by-route, including generated pages, legal pages, pricing pages, integration pages, 404 pages, and programmatic content routes if linked or published.
- Audit component-by-component across routes: build a widget inventory and compare every repeated widget type across pages before finalizing. See components.md.
- Extract repeated markup into reusable components/partials/includes/framework primitives when the stack supports it; if extraction is not feasible, normalize the markup and document why duplication remains.
- Compare real screenshots against the reference. "Looks better" is not evidence.
- Fix root component causes when an issue repeats across pages: navigation, footer, cards, buttons, typography, spacing tokens, image rules, grid primitives.
- Re-render and re-screenshot after each meaningful group of fixes.
- Preserve content, brand intent, and information architecture unless the user asks for copy or structure changes.
- Keep changes scoped; do not rewrite the entire design system when a token, component, template, or layout rule fixes the defect.
- Run available validation before final delivery: build, lint, stylelint, tests, visual capture, accessibility checks, Lighthouse, and the geometry sweep in defects.md.
- Never declare the pass complete while known visible issues remain.
### Parallelizing across agents (optional)
- Give each agent a DISJOINT set of files. Two agents editing the same file collide and overwrite each other.
- Do the shared foundation (shared components, tokens, shared data modules) first and serially, before fanning out page-level work that depends on it.
- Verify centrally after all agents finish, with programmatic sweeps and a rebuild. Per-agent self-reports are not the gate.
- Trust but verify each report; re-read a file before acting on a surprising claim (an agent may read a file mid-edit and report it broken when it is not).
## Phase 1: Discover Context
Determine before capturing or editing:
- Project root and package manager.
- Build system and templating mechanism (framework-agnostic; the workflow does not depend on a specific framework).
- Source locations for templates, components, global CSS, component CSS, JavaScript, image assets, fonts, config, generated output.
- Local dev URL and whether a server is already running.
- Reference source: live URL, screenshot folder, design export, previous production site, or written design standard.
- Route scope: entire site, root only, a section, or a list of specific routes.
Read the project's existing scripts before inventing commands. If a dev server is needed and none is running, start it; if the standard port is occupied, use the existing server when it matches the project, otherwise pick another port.
## Phase 2: Inventory Routes
Build a route list from: linked anchors on homepage/primary nav; static-build output directories; sitemap files; route manifests/content data/collection templates/programmatic page data; known required pages from the user.
Exclude only routes that are intentionally external, authenticated admin flows, logout/delete actions, file/mail/tel links, or fragments of pages already covered.
If route generation is broken, fix missing published pages before polishing the rest of the site. A polished 404 for a page that should exist is still a failure.
### Diff-driven re-audit scope
On a re-audit (not a first audit), scope the route list to what the diff touched. Re-running every route every PR wastes time; missing a route the diff DID touch ships a regression.
1. Get the diff: `git diff --name-only main...HEAD` (or the appropriate base).
2. Filter to source-of-truth files: templates, components, global CSS, design tokens, layout primitives, route data, content collections. Exclude tests, fixtures, generated output, lockfiles, docs.
3. Apply blast-radius rules:
- Page-level template change scopes to that page only.
- Shared widget edit scopes to EVERY route that imports the widget transitively. Re-capture every importing route; do not trust "this page does not use it" without re-checking the import graph.
- Global token or layout primitive (shared spacing scale, color, layout shell) scopes to every route.
- Route-data change (sitemap, route manifest, collection schema) scopes to every generated page.
4. Resolve the import graph from the build tool's module graph (or a project script that emits one), not from grep; grep against component names misses re-exports and aliases.
5. Re-audit set is the intersection of (routes in scope) with (routes the diff touched transitively). Capture and sweep every route in the set at both viewports.
## Phase 3: Inventory Repeated Widgets
Before editing styles, build a cross-page widget inventory. Mandatory for any multi-page polish. The 11 widget families and the inventory record format live in components.md. Per family record: routes where it appears, source that renders it, required/optional content fields, allowed variants, canonical visual contract, intentional-vs-accidental deviations.
- Extraction rule (same as components.md): extract at three or more duplicates, or at two with clear future reuse, or any visible drift.
- Do not keep two visually different implementations of the same widget type unless they have distinct semantic purposes and named variants.
## Phase 4: Capture Baseline
Capture every in-scope route at both audit viewports before editing. Store in a timestamped audit directory. Capture both viewport and full-page screenshots when useful.
Filename convention (load-bearing for mechanical before/after comparison):
- `<route>_desktop.png`
- `<route>_desktop_full.png`
- `<route>_mobile.png`
- `<route>_mobile_full.png`
- `reference_desktop.png`
- `reference_mobile.png`
- `report.json`
Run from a headless browser of your choice (Puppeteer, Playwright, or equivalent). If the project uses another driver, port the loop body; the file naming convention is load-bearing, not the driver. Install the automation library (for the example below: `npm install --save-dev puppeteer`), save as `capture.cjs`, invoke with `node capture.cjs <output-dir>` (defaults to `visual-audit/`).
```js
const fs = require("fs");
const path = require("path");
const puppeteer = require("puppeteer");
const baseUrl = process.env.TARGET_URL || "http://localhost:3000";
const outDir = process.argv[2] || "visual-audit";
const routes = ["/"];
const viewports = [
{ name: "desktop", width: 1440, height: 900 },
{ name: "mobile", width: 375, height: 812 },
];
const safeName = (route) =>
(route === "/"
? "home"
: route.replace(/^\/|\/$/g, "").replace(/[^a-z0-9]+/gi, "_").toLowerCase()
) || "home";
(async () => {
fs.mkdirSync(outDir, { recursive: true });
const browser = await puppeteer.launch({ headless: "new" });
const failures = [];
for (const route of routes) {
for (const vp of viewports) {
const page = await browser.newPage();
await page.setViewport({ width: vp.width, height: vp.height, deviceScaleFactor: 1 });
try {
const response = await page.goto(`${baseUrl}${route}?qa=${Date.now()}`, {
waitUntil: "networkidle0", timeout: 30000,
});
if (response && response.status() >= 400) {
failures.push({ route, viewport: vp.name, status: response.status() });
}
const file = path.join(outDir, `${safeName(route)}_${vp.name}.png`);
await page.screenshot({ path: file });
await page.screenshot({ path: file.replace(".png", "_full.png"), fullPage: true });
} catch (error) {
failures.push({ route, viewport: vp.name, error: String(error.message || error) });
} finally {
await page.close();
}
}
}
await browser.close();
fs.writeFileSync(path.join(outDir, "report.json"), JSON.stringify({ baseUrl, routes, failures }, null, 2));
})();
```
Adapt the script rather than rewriting it. Add route discovery, contact sheets, interactive captures, and the geometry sweep from defects.md as the task requires.
### Authenticated and stateful route capture
Logged-in routes, multi-step flows, and screens that depend on stored state (theme, locale, feature flags, cart contents) need seeded state before screenshotting. Public-only capture misses the bulk of any real product.
- Seed cookies, `localStorage`, and `sessionStorage` at browser launch, not via a UI flow per capture. The flow runs once to record state; every subsequent capture reuses it.
- Drive the multi-step flow in one capture session: navigate to login, submit credentials, wait for the post-login redirect, set required state (select org, accept terms, dismiss onboarding), THEN screenshot the target route.
- Snapshot post-login storage with the driver's facility (Playwright `storageState`, Puppeteer `page.cookies()` plus `localStorage` dump) and reuse it across every authenticated capture: one login per audit, not one login per route.
- Refresh the snapshot when the auth schema changes or the session expires; detect via an HTTP 401 on the first authenticated request, then re-record and retry.
- Use a dedicated audit account (not a real user) with deterministic data; seed the database with known fixtures so screenshots compare cleanly across runs.
- Tag each authenticated capture so the diff tool knows it depends on seeded state; a pixel diff against a fresh login on a different day drifts even when the UI did not change.
## Phase 5: Capture Reference
Capture the reference at the same viewport sizes as Phase 4. If the reference is a live site, use cache-busted URLs and the same browser engine as the baseline. If it is a screenshot or design export, place assets next to local screenshots for one-command side-by-side comparison.
When comparing to a reference:
- Match polish level, not necessarily exact color or content.
- First viewport first: navigation, hero, primary CTA, image treatment, first hint of next section.
- Then section rhythm: density, vertical spacing, card grids, media-to-copy balance, CTA placement.
- Finally details: shadows, borders, radii, icon alignment, label treatment, focus states, hover states, mobile drawer behavior, footer density.
## Phase 6: Audit Each Route
For every route and viewport, record findings as: route + viewport + component + what is wrong + why it matters + likely source + screenshot + fix status.
| Category | What to inspect |
|----------|-----------------|
| Header and navigation | Logo alignment, nav item spacing, dropdown centering, active state, hover/focus/pressed, sticky behavior, z-index, seam lines, mobile drawer, scroll lock |
| Hero | First-viewport composition, heading scale, line length, CTA alignment, media size, image sharpness, next-section reveal, mobile fold |
| Sections | Vertical rhythm, background bands, max-widths, gutters, content density, section transitions |
| Cards and tiles | Equal heights in rows, equal widths, consistent padding, aligned baselines, clickable area, hover, focus ring, icon and image alignment, aspect ratio |
| Buttons and links | Size, label clarity, icon centering, hover drift, duplicate arrows, disabled states, touch target, focus visibility |
| Typography | Family, weight, scale, line-height, letter-spacing, wrapping, measure, hierarchy, alignment |
| Color | Background, foreground, accent consistency, contrast, border, hover, active, muted text, light and dark bands |
| Images and media | Crop, aspect ratio, resolution, `srcset`, lazy and eager loading, alt text, visual relevance, repeated or placeholder assets |
| Forms | Labels, placeholders, validation states, focus, disabled, field heights, mobile keyboard fit |
| Footer | Layout balance, link columns, legal row, social icon spacing, hit area, mobile stacking |
| Responsive behavior | No horizontal scroll, no clipping, no desktop layout on mobile, no text overflow, no oversized buttons or cards, no tiny tap targets |
Compare each widget against other instances of its family: same padding/radius/border/hover/min-height/CTA position/typography; same CTA label style; one image/logo/fallback treatment for tiles; same eyebrow/H2/subhead/width/gap for section headers; named hero variants with shared internals; explicit (not accidental) component variants.
## Phase 7: Prioritize Fixes
Fix in tiered order; higher tiers always block lower tiers.
1. Broken routes, 404s, missing generated pages, hard layout failures.
2. Global shell defects: header, navigation, footer, body overflow, typography tokens, color tokens.
3. Cross-page widget drift: same widget type implemented differently across pages without a named variant.
4. Cross-page component defects: cards, buttons, pricing, integration tiles, form controls, media blocks.
5. Page-specific hierarchy/density; interaction and accessibility; asset/performance polish; minor copy clarity.
Prefer global fixes when a defect repeats; prefer page-level fixes when the defect is truly contextual.
## Phase 8: Patch With Component Discipline
When repeated widgets drift, fix the component system before tuning page-specific CSS. Full extraction sequence, component contract checklist, and anti-patterns (copy-paste tweaking, near-duplicate components, one-off CSS for drift, overly generic components needing per-call-site overrides) live in components.md. Summary: choose a canonical contract, name widget and variants, move markup into the component mechanism, pass content as explicit fields, make variants explicit, centralize CSS, re-render every route, compare instances at both viewports.
## Phase 9: Patch With Design Discipline
Use existing design primitives before adding new ones. Patch CSS, templates, and components conservatively. Token detail: design.md. Layout primitives: responsive.md. Contrast: accessibility.md. Image strategy: performance.md.
- Normalize tokens first: spacing, color, border, radius, shadow, type scale.
- Use stable dimensions for repeated UI: `min-height`, `aspect-ratio`, grid tracks, `align-items: stretch`, fixed icon boxes.
- Avoid arbitrary one-off margins; use local precedent or none.
- Use `gap` for internal spacing instead of stacked margins where possible.
- Use `max-width` and responsive constraints for readable line lengths.
- Use full-card anchors when a card visually behaves as a link; do not nest anchors.
- Keep CTA hover transforms from stacking with card hover transforms.
- Use `:focus-visible` rings that are clearly visible and consistent.
- Avoid hiding visible text behind a mismatched `aria-label`; visible text is the accessible name.
- Make hover and focus states visually related so a keyboard user gets the same clarity as a pointer user.
- Use `srcset` and `sizes` for recurring large images.
- Use `loading="eager"` and `fetchpriority="high"` only on the hero LCP image; use `loading="lazy"` and `decoding="async"` for below-the-fold imagery.
## Phase 10: Verify After Every Fix Group
After a meaningful fix group:
1. Rebuild or let the dev server hot reload.
2. Re-capture the affected routes at desktop and mobile.
3. Compare before and after.
4. Inspect at least the first viewport and the edited section.
5. Confirm no regression on shared components.
6. If a reusable widget changed, capture every route where the widget appears, not only the route where the defect was first noticed.
Do not wait until the end to discover a global CSS fix broke mobile.
### Side-by-side diff tooling
Eyeballing two tabs misses small pixel shifts that compound. Pick the tool by cadence:
| Tool | Strength | Use when |
|------|----------|----------|
| pixelmatch | Raw pixel diff, single dependency, scriptable | One-off baseline vs after; quick local sanity check; custom CI gate where the diff threshold is the only signal |
| reg-suit | Git-aware visual regression, stores baselines per branch, reports diff to PR | Team wants a history of baselines tied to commits; self-hosted alternative to a hosted service |
| Playwright `toMatchSnapshot` | Built into the test runner, lives next to the suite | CI gate runs as part of an existing Playwright job; one toolchain; tolerable for small surfaces |
| Chromatic | Hosted, integrates with a component playground, design-review UI for non-engineers | Design system with many stakeholders needing per-change comments; budget for a paid hosted service |
Decision rule: one-off diff -> pixelmatch; CI gate alongside other tests -> Playwright `toMatchSnapshot`; history of baselines with PR-level diff comments and no hosted service -> reg-suit; design-system review surface for stakeholders -> Chromatic.
Never approve a baseline on green CI alone. A human reviews the rendered comparison images (not just the percentages) before the baseline is promoted.
## Phase 11: Programmatic Geometry Checks
Use browser automation to catch what screenshots miss: viewport bleed, hidden text overflow, sub-44 hit targets, duplicate arrows, dropdown miscentering, mobile drawer scroll-lock failures, focus invisibility.
The canonical 9-check geometry sweep, its JS snippet, and per-check thresholds live in defects.md. Run on every audited route at both capture viewports; a run is clean when every check returns zero issues.
Filter false positives only when you can explain them (hidden off-canvas content, intentionally overflowing dropdown internals that do not affect the page viewport) and document the filter so the next run does not re-discover it.
Run BOTH sweeps: the geometry sweep (visual, headless browser) and the content-and-markup sweep (SEO and HTML validity, Node over built HTML). See defects.md. A route is not signed off until both return clean.
## Phase 12: Component Drift Checks
Use the drift collector to compare same-family widgets across pages. Catches drift early; does not replace visual judgment. The snippet and property list (width, height, padding, radius, border, background, shadow, typography, CTA position, image metrics) live in components.md. Run on every route in scope; compare each family across pages.
Flag any difference not explained by a named variant. A family is drift-free when every same-variant instance produces the same metrics within rounding tolerance.
## Phase 13: Interaction QA
Capture explicit interaction screenshots: desktop dropdown open, mobile menu open, hovered card or button, focus-visible state on major controls, pricing card hover/focus if cards animate, and modal/drawer/accordion/tabs/carousel/form-validation states if present.
Behavioral checks:
- Dropdowns are centered or intentionally aligned.
- Dropdowns stay within the viewport.
- Esc closes popovers.
- Outside click closes popovers.
- Mobile drawer locks page scroll while open and restores scroll after close.
- Buttons do not drift on hover.
- Active and current nav states are visible.
- Focus ring is not clipped.
- Interactive element accessible names match visible labels.
State coverage (empty, loading, success, error, partial, disabled, read-only, stale, offline, unauthorized, limit-reached, initial, done): see ui-ux.md.
## Phase 14: Accessibility Validation
Use Lighthouse, axe, or equivalent as baseline checks, not replacements for human visual inspection. Full WCAG 2.2 AA framework, ARIA, contrast targets, keyboard patterns, screen reader checks, focus management: see accessibility.md.
Minimum basics for multi-page polish:
- `<html lang>` exists and is valid on every route.
- Exactly one primary `<main>` per route.
- Logical heading order: one H1 per route, sequential H2 to H3.
- Links and buttons have accessible names.
- Form controls have labels.
- Visible labels match accessible names.
- Images have useful alt text or empty decorative alt.
- Contrast passes for text, UI controls, and focus indicators in both light and dark mode.
- Focus order follows visual order.
- No keyboard traps.
- Skip link works and does not create viewport overflow while hidden.
If a visual fix creates an accessibility problem, the visual fix is incomplete.
## Phase 15: Lighthouse and Performance Polish
Run Lighthouse when the project has it set up or the user asks for production polish. Fix failing assertions; treat warnings as triage without over-optimizing at the cost of design unless asked. Setup, scoring weights, audit-by-audit fixes: lighthouse.md. Performance mechanics and asset strategy: performance.md.
Common visual-performance fixes that surface in audit work:
- Add responsive `srcset` variants for hero and recurring large images.
- Use correct intrinsic `width` and `height` on every image to prevent layout shift.
- Preload or prioritize only the actual LCP image.
- Avoid loading offscreen imagery eagerly.
- Remove giant decorative images visually indistinguishable from smaller assets.
- Keep webfont usage intentional: cap to two families and four weights.
- Avoid expensive shadows and filters on large scrolling surfaces if they cause jank.
## Phase 16: Reference-Level Design Heuristics
Yes-or-no judgments supported by the captured screenshots when comparing the audited site to the reference. The same 16 live in design.md "Reference Comparison Heuristics"; the two copies are intentionally identical so each file is self-contained for its reader's mode.
1. First viewport has a clear hierarchy and does not feel accidental.
2. The brand or product is visible immediately when relevant.
3. The next section peeks intentionally rather than crowding the hero.
4. Repeated cards in a row align on top edges, bottom edges, and CTA baselines.
5. Text blocks have readable line lengths and consistent rhythm.
6. Labels, eyebrows, and badges use one visual language.
7. Buttons use one size system and one radius system.
8. Icons sit optically centered, not just mathematically centered.
9. Borders are subtle and consistent.
10. Shadows support hierarchy but do not muddy the palette.
11. Alternate background bands feel deliberate.
12. Mobile layout feels designed, not merely stacked.
13. Footer spacing is calmer than the body, with no stray gaps or cramped links.
14. Empty states, legal pages, and 404 pages share the same polish as marketing pages.
15. Same-family widgets look like members of one system across pages.
16. Variants are recognizably intentional, not accidental drift.
A site at reference level passes every test; a site at "fine" level fails three or more.
## Phase 17: Common Defects and Fixes
The 29-row symptom-to-fix lookup table lives in defects.md. Use it whenever a defect surfaces during Phase 6 or Phase 13. Apply the standard fix at the right layer (component, page, or design token), then re-run the geometry sweep on the affected route.
## Phase 18: Deliverables
Provide a concise final package:
- Summary of main changes.
- Per-page checklist: issues found, fixes applied, screenshot references.
- Before-and-after screenshot paths for desktop and mobile.
- Reference screenshot paths.
- Interactive screenshot paths if menus, drawers, or cards were tested.
- Validation commands and pass-or-fail results.
- Remaining risks only if real issues remain.
- Widget inventory and standardization notes: which repeated widgets were found, which were extracted or normalized, which named variants remain.
Per-page checklist format:
```markdown
| Route | Issues found | Fixes applied | Before | After |
| --- | --- | --- | --- | --- |
| `/pricing/` | Pricing CTA drift, partial card click area | Full-card anchors, stable hover state | [D](before/pricing_desktop.png) [M](before/pricing_mobile.png) | [D](after/pricing_desktop.png) [M](after/pricing_mobile.png) |
```
Widget inventory format:
```markdown
| Widget family | Routes | Source | Standardization action | Variants |
| --- | --- | --- | --- | --- |
| Integration tile | `/integrations/`, `/whatsapp/`, `/for/.../` | duplicated markup plus partial | Extracted to `integration-card` | `link`, `static-logo` |
```
Validation summary format:
```markdown
- Build: passed
- Lint: passed
- Capture: 24 routes, `failures: []`
- Geometry sweep: `[]` at `1440x900` and `375x812`
- Drift collector: zero unexplained differences across families
- Lighthouse: passed required assertions
```
## Phase 19: Final Acceptance Gate
Authoritative gate for multi-page audit and polish work (the Self-Improvement section in SKILL.md is the short version). Confirm every item:
- Every in-scope route was captured at desktop (`1440x900`) and mobile (`375x812`).
- Every route returns the expected HTTP status.
- Repeated widget families have been inventoried and compared across pages.
- Same-purpose widgets have been extracted into reusable components/partials, or normalized to one contract where extraction is not feasible.
- Remaining variants are named, intentional, and visually consistent within their variant.
- Every edited shared component was re-captured on every route where it appears (not just the route where the defect was noticed) and the geometry sweep passed.
- The latest screenshots reflect the latest code.
- Build and lint pass, or failures are clearly unrelated and reported.
- No horizontal scroll exists on mobile.
- No known text overflow, clipped controls, duplicate arrows, tiny touch targets, or partial clickable-card defects remain.
- The geometry sweep returns zero issues on every audited route at both capture viewports.
- The drift collector returns no unexplained differences for any widget family.
- Navigation, footer, pricing, cards, forms, and interactive states pass Phase 13 interaction QA and Phase 14 accessibility validation.
- Accessibility basics from Phase 14 are clean on every route.
- Reference-Level Design Heuristics from Phase 16 pass.
- The final checklist is written and linked.
If any item fails, continue fixing or clearly state the blocker. Do not present an incomplete visual pass as complete.
## Audit Cadence and Ownership
A one-shot audit ages. Without a cadence and a clear owner per surface, the polish bar drifts back to "fine" within a quarter. Lock in both before declaring complete.
### Cadence
- Every release-candidate build: full geometry sweep, content-and-markup sweep, Lighthouse on critical routes, axe on every route. Block the release on any new regression.
- Every shared-widget edit: re-audit every route that imports the widget (per the diff-driven re-audit scope in Phase 2). Treat shared-widget PRs as cross-page changes, not local edits.
- Weekly drift sweep: run the geometry sweep, content-and-markup sweep, drift collector, and a Lighthouse pass against a fixed set of canonical routes; compare against the previous week's baseline; file issues for any regression.
- Quarterly reference comparison: re-run Phase 16 heuristics against current production. Match the reference's level of discipline today, not last year.
### Ownership
Pick ONE model and write it down. Mixed ownership produces dropped polish work.
- Route-owning team: the team that ships features on a route also owns its geometry sweep, Lighthouse score, and drift findings. Works when teams are vertically aligned to surfaces.
- Polish rotation: a dedicated rotating engineer (or pair) for one to two weeks runs the weekly drift sweep, files issues against owning teams, and drives the highest-impact fixes. Works when teams are horizontally aligned and no single team owns the visual bar.
Either way, polish work is named work on the roadmap, not an "if there is time" residual. The bar drops the moment polish is the first thing to slip.
## See Also
- components.md for extraction discipline, contracts, and drift detection
- defects.md for the symptom-to-fix lookup table and the canonical geometry sweep
- pre-launch.md for the launch gate that runs alongside this workflow
- design.md for tokens, typography, color, and shadow language
references/build-hygiene.md
---
title: Build Hygiene
purpose: Bundle, tree-shake, code-split, and audit dependencies so every shipped kilobyte has a line item and the JS budget is gated in CI, not aspirational.
load-when:
task-keywords: [build, tree-shaking, dependency, sideEffects, lockfile, dead code, code splitting, bundle, dead code, audit]
symptoms: [bundle size grew, slow page, score dropped, LCP regression]
prereq: SKILL.md
related: [performance.md, security.md, observability.md, testing.md]
size: ~362 lines
---
# Build Hygiene
Every shipped kilobyte is paid for: by the user's network, by parse-and-compile CPU, by main-thread execution. Bundle hygiene makes that cost an auditable line item.
## Why Bundle Hygiene Matters
| Fact | Value |
|---|---|
| Parse-and-compile cost (mid-range Android) | ~50 ms per 30 KB gzipped JS |
| Execution cost under cold start | +100 to 200 ms per 30 KB gzipped JS |
| Initial-route JS budget | 90 KB compressed (decompresses to ~270 KB of code) |
Two refusals:
- "Budgets are aspirational." A budget not gated in CI is a wish. Gate budgets with `size-limit` and `bundlesize` (see testing.md).
- "It is only one more dependency." A real audit found an unused export pulled in by a barrel re-export accounted for 14 KB of the initial bundle: a dependency-of-a-dependency nobody read, kept in by a side-effects misdeclaration despite tree-shaking that should have removed it.
Discipline: every dependency has a line item, every line item has an owner, every owner can defend the bytes.
## Tree-Shaking at Depth
Tree-shaking is dead-code elimination on ESM imports. Three preconditions, all required:
- ES modules end-to-end
- accurate `sideEffects` declarations
- static (analyzable) imports
### ESM-only imports
CommonJS resists shaking: module shape is not statically known until execution, so `require('lodash')` pulls every export. `import { debounce } from 'lodash-es'` pulls only `debounce` plus its transitive imports.
| Check | Threshold/target |
|---|---|
| Authored packages declare module type | `"type": "module"` in `package.json`, or every authored file uses `.mjs` |
| Dependencies expose ESM | each has an `"exports"` map with an `"import"` condition, not just `"require"` |
| Shipped code is CJS-free | search the production bundle: no `require(` calls remain |
### `sideEffects` in `package.json`
`"sideEffects": false` tells the bundler that importing for named-export retrieval has no observable side effect, so unused imports drop:
```json
{
"name": "your-pkg",
"type": "module",
"sideEffects": false
}
```
Declare side-effectful files explicitly (a CSS import, a polyfill registration, a global mutation):
```json
{
"sideEffects": ["**/*.css", "./src/polyfills.js"]
}
```
Omitting the `sideEffects` field is the worst case: the bundler must assume every import is load-bearing and ship everything reachable.
### The three traps
- Re-export barrels: `export * from './a'; export * from './b';` forces the bundler to walk every transitive module; if any module has a side effect or resists analysis, the whole barrel ships. Prefer deep imports (`import { foo } from 'pkg/foo'`) over barrel imports (`import { foo } from 'pkg'`) for any non-trivial library.
- Default exports: a default object resists property-level shaking (the bundler cannot prove only one property is used). Convert default exports to named exports for any module with more than one logical export.
- Dynamic property access: `lib[methodName]()` or `import(`./${variable}.js`)` prevents static export determination; shaking falls back to including everything. Replace with switch statements or named lookups.
Check: build a production bundle, open the analyzer, search for any package supposedly imported for one function. If the rest of the package is present, one of the three traps is in play.
## `sideEffects` Declarations and How to Verify
Two declaration levels: package (`package.json`) and file (a build-tool comment).
### Package-level
| Pattern | `sideEffects` value |
|---|---|
| Pure utility library, no globals | `false` |
| Library with one polyfill entry, otherwise pure | `["./src/polyfills.js"]` |
| Library that registers global event listeners on import | omit (forces pessimistic inclusion) |
### File-level
Use the `/*#__PURE__*/` annotation on factory calls so the bundler drops them if the result is unused:
```javascript
export const validator = /*#__PURE__*/ buildValidator(SCHEMA);
```
Without it, `buildValidator(SCHEMA)` is presumed side-effecting and stays even if `validator` is never imported.
### How to verify
| Tool | Use | Cadence |
|---|---|---|
| Bundle analyzer (`webpack-bundle-analyzer`, `rollup-plugin-visualizer`, `esbuild --analyze`, `vite-bundle-visualizer`) | eyeball the largest squares | every production build |
| Source map explorer | trace each kilobyte back to its source file; unrelated packages = side-effect leaks | per investigation |
| Diff between releases | flag any dependency that grew by more than 5 KB or any new dependency over 10 KB | every release |
## Code-Splitting Strategy
Ship only the code the current view needs. Three granularities.
### Route-level
One chunk per route, loaded on navigation. The default in modern frameworks and the highest-leverage split.
Check: on the landing route's network tab, the number of JS requests matches framework runtime plus the route entry, not the whole app.
### Component-level
Lazy-load a heavy widget behind a Suspense or loading boundary using the platform `import()`:
```javascript
async function openEditor() {
const { Editor } = await import('./editor.js');
mount(Editor);
}
```
Earns its cost when the widget is over 30 KB compressed and used by fewer than half of route visitors. Below that, the split adds latency without saving bytes for the typical user.
### Interaction-level
Load on intent (hover, focus, intersection), guarded by a `loaded` flag:
```javascript
const trigger = document.querySelector('#open-modal');
let loaded = false;
trigger.addEventListener('pointerenter', async () => {
if (loaded) return;
loaded = true;
await import('./modal.js');
});
```
Below-the-fold widgets: load when the viewport approaches via IntersectionObserver with `{ rootMargin: '200px' }`:
```javascript
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
import(entry.target.dataset.module);
observer.unobserve(entry.target);
}
}
}, { rootMargin: '200px' });
```
### Decision matrix
| Scenario | Strategy |
|---|---|
| Per-route view | Route-level split (default) |
| Heavy widget, used by some visitors | Component-level lazy |
| Below-the-fold widget, used by many | Interaction-level (intersection) |
| Modal opened by a button | Interaction-level (hover or focus) |
| Hot path used by everyone | Bundle eagerly (no split) |
| Auth-gated module | Route-level split plus auth check |
## Dependency-Cost Discipline
Every install is a budget decision. Three checkpoints.
| Checkpoint | Rule |
|---|---|
| Size gate (bundlephobia or equivalent) | Reject any production dependency over 30 KB minified-and-gzipped without a clear defended reason; reject any over 100 KB without a one-paragraph PR justification. |
| Transitive tree | Before merge, run `npm install --dry-run <pkg>` and inspect the tree; a direct dependency pulling 40 transitive deps differs from one pulling two. Count transitives as part of the cost. |
| Platform-first justification | Every dependency added in the last sprint has a PR comment explaining why the platform was not enough. |
The "one dep doubled the bundle" failure mode: a date library added to format two timestamps eagerly loads its own locale data (200 KB), doubling the bundle. Responses in order of preference:
- Use the platform: `Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat` (see i18n.md).
- Pick a slimmer alternative with tree-shaken, scoped imports over one that imports every locale at module load.
- Write the utility yourself: five lines of `String.prototype.padStart` beat 30 KB of dependency in many cases.
## Lockfile Hygiene
The lockfile is the source of truth for reproducible builds.
| Check | Rule |
|---|---|
| Lockfile committed | Always commit `package-lock.json`, `pnpm-lock.yaml`, or `yarn.lock`; without it CI produces different bundles each run and regressions become unattributable. |
| CI install command | Use `npm ci` (or `pnpm install --frozen-lockfile`, `yarn install --immutable`): installs exactly what the lockfile pins and fails on `package.json`/lockfile disagreement. Never plain `npm install` in CI. |
| Scan CI config | Search for `npm install` without `ci`; every occurrence is a defect. |
| Dirty-lockfile gate | CI fails if the lockfile is dirty after install: `git diff --exit-code package-lock.json`. |
| Review lockfile diff | The lockfile is reviewed in every PR that touches `package.json`. |
CI install step:
```yaml
- run: npm ci
```
### The `npm audit` budget
```text
npm audit --audit-level=high --omit=dev
```
Reject high and critical vulnerabilities in production dependencies. Track and triage moderate vulnerabilities on a weekly or sprint-aligned cadence. Dev dependencies are filtered out because they do not ship to users. See security.md for Subresource Integrity and the broader supply-chain story.
## Dead-Code Elimination
Dead code (provably unreachable) and unreferenced-but-imported code look similar but have different fixes.
### Minifier configuration
Modern minifiers (`terser`, `swc`, `esbuild` in minify mode) do dead-branch elimination as part of compression. Verify in CI:
- The production build sets `NODE_ENV` to `production` so dev-only branches drop out.
- The minifier enables `dead_code`, `unused`, `pure_funcs`, and `passes: 2` (terser) or the framework equivalent.
- Production builds contain no string literals from dev-only code paths (search the bundle for known dev-only error messages).
### Fix table
| Pattern | Fix |
|---|---|
| Dead branch (`if (false) { ... }`) | Trust the minifier; verify the branch is gone in the prod bundle. |
| Unreferenced import with side effects | Remove the import, or accurately scope `sideEffects` so it can be dropped when unused. |
| Imported export, never read | Verify the package has `sideEffects: false`; if not, file an upstream issue or patch. |
## `import()` Analysis
The bundler determines lazy chunks by walking every `import()` at build time. Shape matters:
```javascript
import('./modal.js'); // Static path: produces one chunk.
import(`./views/${viewName}.js`); // Template with literal head: one chunk per matching file.
import(routeModule); // Pure variable: not analyzable; falls back to one big chunk or fails.
```
Prefer the first two; avoid the pure-variable form in production code (it defeats code-splitting).
### Magic comments and bundler equivalents
```javascript
import(/* webpackChunkName: "editor" */ './editor.js');
import(/* webpackPrefetch: true */ './settings.js');
import(/* webpackPreload: true */ './critical-modal.js');
```
`webpackChunkName` produces stable file names (better cache hit rate); `webpackPrefetch` adds `<link rel="prefetch">` (fetched when idle); `webpackPreload` adds `<link rel="preload">` (immediately needed).
| Bundler | Chunk naming | Preload signal |
|---|---|---|
| Webpack | `/* webpackChunkName */` | `/* webpackPreload */` |
| Vite (Rollup) | `chunkFileNames` config | Manual `<link>` tag |
| Rollup | `output.chunkFileNames` | Manual `<link>` tag |
| esbuild | `chunkNames` config | Manual `<link>` tag |
| Parcel | Automatic | Manual `<link>` tag |
For bundlers without inline preload directives, add the hint to the document head:
```html
<link rel="prefetch" href="/assets/editor.abc123.js" as="script">
```
## Source Maps for Builds
Source maps make production stack traces readable without shipping unminified code.
| Pattern | What it does | When |
|---|---|---|
| Inline (`//# sourceMappingURL=data:`) | Embeds the map in the bundle | Dev only; never production |
| Public (`//# sourceMappingURL=file.js.map`) | Map sits next to the bundle, browser fetches it | OK for open-source; leaks source for proprietary |
| Hidden (no comment, build emits the map) | Map built but not referenced; upload to error tracker | Production for proprietary code |
Production pattern: build hidden source maps, upload them to the error tracker on deploy, never serve them from origin. Users see a minified bundle; the team sees real frames.
```text
# Webpack
devtool: 'hidden-source-map'
# Vite
build.sourcemap: 'hidden'
# Rollup
sourcemap: 'hidden'
# esbuild
sourcemap: 'external'
```
Upload step in CI (error trackers support similar commands):
```text
sentry-cli sourcemaps upload --release "$VERSION" ./dist
```
See observability.md for the full error-capture pipeline that depends on the source-map upload.
## Polyfill Discipline
Ship the minimum polyfills the supported browsers need, never more.
### `browserslist` as the source of truth
One list in `package.json`, read by PostCSS, Babel, Autoprefixer, and the ESLint browser-compat plugin. Do not duplicate per tool:
```json
{
"browserslist": [
">0.2%",
"not dead",
"not op_mini all"
]
}
```
Audit the resolved list at least once per quarter: run `npx browserslist`, drop browsers below 0.2 percent usage and vendor-unsupported browsers.
### `core-js` minimum-target subsets
Configure `core-js` with the same `browserslist` so only used polyfills are added:
```json
{
"presets": [["@babel/preset-env", {
"useBuiltIns": "usage",
"corejs": { "version": "3.36", "proposals": false }
}]]
}
```
`useBuiltIns: "usage"` adds only the polyfills the code uses; `"entry"` and the global import are bigger and rarely justified.
### Modern and legacy bundles
Differential serving ships a modern bundle to modern browsers and a transpiled legacy bundle to older ones:
```html
<script type="module" src="/assets/modern.js"></script>
<script nomodule src="/assets/legacy.js"></script>
```
Pay the two-bundle build complexity only when the legacy share is over 10 percent of traffic and the modern bundle saves over 20 KB compressed. Below those thresholds, ship one transpiled bundle.
Check: the modern-browser production bundle does not include `Array.prototype.flat` polyfill, generator runtime, or async-await transforms. If it does, `browserslist` is wider than the audience.
## Build Plumbing (Footnote)
Not bundle hygiene proper, but it breaks builds:
- Exclude tool output directories (audit scratch dir, screenshot dumps, generated reports) from the dev/preview file watcher; otherwise the watcher loops on its own output and the preview server thrashes.
- Date-shard or namespace generated audit artifacts so runs do not overwrite each other.
## See Also
- [performance.md](performance.md): budget framing, render strategies, and the asset-loading rules bundle hygiene feeds.
- [security.md](security.md): Subresource Integrity, supply-chain, and dependency vulnerability gates.
- [observability.md](observability.md): hidden source-map upload and the error-tracking pipeline that depends on it.
- [testing.md](testing.md): `size-limit`, `bundlesize`, lighthouse-ci, and the CI checks that make budgets auditable.
- [seo.md](seo.md): the clean-URL contract (flat-file build output and the static-host audit gotcha).
references/debug-recipes.md
---
title: Debug Recipes
purpose: Symptom-driven recipes for the regressions that recur on every project. Each recipe runs Symptom, Reproduce, Isolate, Hypothesise, Verify so the path from sighting to fix is deterministic.
load-when:
task-keywords: [debug, recipe, hydration mismatch, INP, CLS, layout overflow, focus trap, font-swap CLS, axe, lighthouse, errors-in-console]
symptoms: [hydration mismatch, INP regression, CLS regression, focus trap leak, horizontal scroll, viewport overflow, font swap CLS, Lighthouse score drop]
prereq: SKILL.md
related: [defects.md, performance.md, observability.md, lighthouse.md]
size: ~332 lines
---
# Debug Recipes
Recipes for the regressions that recur on every project. Each fits one shape: Symptom (what you saw), Reproduce (smallest path back to it), Isolate (turn the suspect off), Hypothesise (canonical root causes ranked by frequency), Verify (the metric or assertion that says it is fixed). Pick by symptom; never run a recipe whose symptom you have not observed.
Verification stack assumed throughout: a headless browser of your choice (Puppeteer, Playwright, or equivalent), Lighthouse, the `web-vitals` library (or `web-vitals/attribution` where stated), DevTools (Performance, Rendering, Coverage, Animations, Lighthouse panels), and `axe`.
## Hydration mismatch
Symptom: a console warning on first paint that the server-rendered DOM did not match the client. Tells: a brief re-render flicker, first-click handlers that fail (listener on the discarded tree), text that flips after a frame.
Reproduce:
- Hard-reload with cache disabled (DevTools Network, "Disable cache" ticked).
- Open the Console BEFORE reload so the warning is captured.
- Confirm the warning fires on every cold load, not one in five. Flaky = date/random-number drift; deterministic = structural.
- Note the exact element path the warning points at (most frameworks log the parent component and the divergent child).
Isolate:
- View source (Cmd+U, View Page Source), find the divergent element in the raw HTML the server sent, then compare to the Elements panel after hydration.
- Classify the divergence: tag mismatch (`<span>` server vs `<div>` client), attribute drift (a class or `data-*` differs), content drift (text differs).
- Disable client-side state hydration for the suspect subtree via the framework's "render on client only" or "suppress hydration warning" escape hatch; if the warning vanishes, the subtree is the suspect.
Hypothesise (cause -> fix, ranked):
| Cause | Fix |
| --- | --- |
| Locale/timezone drift: server formatted a date in UTC, client in the user's locale | Format on the server with an explicit locale and timezone, or render a placeholder server-side and fill in client-side after mount |
| `window`/`document` accessed during render via a `typeof window !== "undefined"` branch | Render the same tree on both sides, then patch in an effect after mount |
| Random IDs from `Math.random()` or `crypto.randomUUID()` during render | Use a stable id source (framework `useId` equivalent, deterministic hash of props, or a prop passed from above) |
| Third-party script (consent banner, analytics tag, extension) mutates the DOM before hydration | Render the third party AFTER hydration completes, or wrap the host element in a stable shell the hydrator can ignore |
| Conditional render based on `localStorage` or cookie (server cannot read `localStorage`) | Move the decision to a cookie the server can read, or render the neutral tree first and patch after mount |
| HTML the parser silently normalises (a `<p>` containing a `<div>` is auto-closed, forbidden content model) | Validate markup with the HTML validator and rewrite to a legal content model |
Verify:
- Cold reload three times in a row; the warning must not fire on any of them.
- Click the first interactive element within 500ms of paint; it must respond on the first click (no discarded-tree handler).
- Record a Lighthouse run; INP attribution must not point at the suspect subtree as the slow event target.
- If using RUM, the hydration error count for the affected route should drop to zero over the next 24 hours.
## INP regression
Symptom: a surface that felt responsive now lags after input. Field metric: 75th-percentile INP rose above 200ms (the WCV threshold) or above the project's own bar.
Reproduce:
- Pick the exact interaction the field data points at (tap a button, type into an input, scroll a list); field data without attribution is a smell, insist on it.
- Throttle the CPU 4x in DevTools Performance and replay the interaction five times; if the lag does not reproduce under throttle the regression is field-specific (device, network, or third party), so record a real-device session.
- Disable browser cache and replay; some INP regressions only surface when a script must be parsed cold.
Isolate:
- Install `web-vitals/attribution` (or an observer exposing `PerformanceEventTiming.target` and the `interactionId`); the payload names the target element and the slowest script.
- Record a Performance trace covering the interaction, identify the long task that overlaps the input, note the script URL and function name.
- Walk back from the long task: which event handler started it, which library frame is on the call stack, which network request blocked the response.
- Disable the suspect script via Network "Request blocking" and replay; if INP returns to baseline the script is the suspect.
Hypothesise (cause -> fix, ranked):
| Cause | Fix |
| --- | --- |
| A third-party script (analytics, A/B test, chat widget, tag manager) shipped a costlier build | Defer, gate behind interaction, or remove |
| A handler ships synchronous compute (sort, parse, format, recompute a derived list) on every keystroke | Move work behind a microtask boundary (`scheduler.yield`, `requestIdleCallback`, or a worker), debounce, or precompute |
| A render cascade: one state change re-renders a tree large enough to blow the budget | Lift the state, memoise, or split the tree at the boundary where the change matters |
| Long Animation Frames stack because a scroll/pointer listener works every frame | Subscribe with `{ passive: true }`, throttle to `requestAnimationFrame`, or move work off the main thread |
| Hydration straggles so the first interaction pays the hydration cost | Hydrate earlier (visible-on-viewport), or render the surface server-only and skip hydration entirely |
Verify:
- Replay the interaction five times with CPU 4x throttle; the slowest event must come in under the project's INP bar.
- RUM (`web-vitals` with attribution) shows 75p INP under 200ms for the route over the next 7 days at minimum population.
- The Lighthouse TBT row does not regress and the long task that caused the regression no longer appears in the trace.
## CLS root-cause hunt
Symptom: Lighthouse reports CLS > 0.1 (mobile) or > 0.05 (desktop), with the score dropping after a recent deploy.
Reproduce:
- Run Lighthouse against the affected URL in a private window with a cold cache (Application, Clear storage).
- Open the Lighthouse "Avoid large layout shifts" row and note every element and its individual shift contribution; the largest one is the suspect.
- Record the Performance panel (reload, stop after settle) and switch to the "Experience" lane where every layout shift draws a red marker.
Isolate:
- Click the largest shift marker in the Performance panel; the bottom pane shows the node that shifted and the node that pushed it.
- Take a screenshot at the start of the recording and at the marker timestamp, then diff visually to see what appeared between the two frames.
- Enable Rendering > Layout Shift Regions; on reload the shift regions paint in blue and the visible blue blob is the culprit.
Hypothesise (cause -> fix, ranked):
| Cause | Fix |
| --- | --- |
| Image without dimensions (an `<img>` with no `width` and `height` attributes reserves zero space) | Declare both `width` and `height` attributes, or set `aspect-ratio` on the parent |
| Web font swap (fallback and webfont have different metrics, pushing content below) | Apply the Font-swap CLS recipe below |
| Late-injected banner, cookie consent, or promo appearing at the top after first paint | Render an empty reserved-height shell on the server and fill it on the client, or render the banner below the fold |
| Embed (video, iframe, social, map) loads without reserved space | Wrap in a fixed-`aspect-ratio` container |
| Animations that move `width`, `height`, `top`, `left`, or `margin` shift content below | Animate `transform: scaleY()` or `opacity` instead and reserve final height up front |
| Async content swap where a skeleton of one height swaps for content of a different height | Size the skeleton to the realistic content height and reuse the same container |
Verify:
- Re-run Lighthouse in a private window with cold cache; the "Avoid large layout shifts" row must report less than 0.1 (mobile) and less than 0.05 (desktop).
- The Performance recording shows no red shift marker after the initial paint.
- The reserved-space fix shows layout settled by the time the LCP element paints (no late nudge below it).
## Accessibility false positive
Symptom: an `axe` rule fires on an element, but a manual screen-reader and keyboard test does not reproduce the failure. You believe the markup is correct.
Reproduce:
- Run `axe` again with a fixed seed (same page, viewport, `axe` version) to confirm the failure is deterministic; if it flakes the page is mutating between scan start and assertion, so capture a stable snapshot first.
- Read the rule's description on the Deque rule page to find the exact assertion it makes (e.g. "every `<button>` must have an accessible name").
- Verify against the spec the rule cites (WCAG SC, ARIA Authoring Practices, HTML5 spec); the rule is a heuristic over the spec and the spec is ground truth.
Isolate:
- In the DevTools Accessibility panel inspect the flagged element's computed Name, Role, State, and Description; if they satisfy the spec the rule is firing on a heuristic that does not apply.
- Reproduce the user task with a real screen reader (VoiceOver, NVDA, TalkBack) and keyboard-only navigation; confirm the user can complete the task without confusion.
- Compare your markup to the WAI-ARIA Authoring Practices reference example for the same pattern; if it matches, the heuristic is wrong about your case.
Hypothesise (cause -> fix, ranked):
| Cause | Fix |
| --- | --- |
| Custom widget with a platform-supplied name (a styled `<select>` carries a programmatic name from its `<label>` but the rule reads the visual wrapper) | The user is fine; the rule reads the wrong node, treat as false positive |
| Decorative element flagged as missing alt (an ornament with `aria-hidden="true"` is correctly hidden) | The rule does not understand `aria-hidden` on the element; treat as false positive |
| Dynamic content invalidates the snapshot (element removed/re-inserted between scan and assertion) | The rule fires on the stale node; capture a stable snapshot first |
| A shadow DOM boundary the rule does not traverse (accessible name comes from a slotted node) | Mark the host with `aria-label` redundantly if the audit tool requires it |
| A genuinely incorrect pattern that happens to work for one assistive tech | Confirm on at least two screen readers (VoiceOver tolerance is not a defence; NVDA or TalkBack may still fail) before declaring a false positive |
Resolve: if at least two screen readers complete the user task AND the spec is satisfied, the rule is a false positive for this case.
- Add an `axe` ignore at the smallest scope (the specific selector or rule on the specific element), not a global suppression.
- Comment the ignore inline with: the rule id, the spec citation, the screen readers verified, the date, and the reviewer initials.
- Re-run `axe` and confirm only the intended ignore is silenced and the rule still fires on other elements.
Verify:
- Manual screen-reader and keyboard pass on the route completes the task without confusion.
- `axe` returns zero violations excluding the documented ignore.
- The next reviewer can read the ignore comment and understand why it is there without asking.
## Layout overflow
Symptom: the page scrolls horizontally on mobile (375 CSS pixels wide) and the defects sweep flags `viewport-bleed` with `scrollWidth` greater than `innerWidth`.
Reproduce:
- Open the page in DevTools Device Toolbar at the iPhone preset (375 wide), confirm horizontal scroll, and drag the document right to note which content reveals.
- Resize the viewport up by 10 CSS pixels at a time until the scroll vanishes, noting the breakpoint at which overflow appears (often a fixed-width child of a flex/grid parent).
Isolate: outline every element and find the one that protrudes.
```css
* { outline: 1px solid red !important; }
```
- Inject the rule via DevTools onto `<html>` so every element draws a red box, then scroll right to find the box extending past the viewport. Inspect that element: compute its width, padding, margin, compare to its parent's content box.
- Alternative: run the defects.md geometry sweep, which flags any element whose `scrollWidth` exceeds its `clientWidth` while `overflow-x` is `visible` (see defects.md).
Hypothesise (cause -> fix, ranked):
| Cause | Fix |
| --- | --- |
| Fixed-width child of a percentage parent (`width: 600px` inside `width: 100%` smaller than 600) | `max-width: 100%`, or `width: 100%` with `max-width: 600px` |
| Long unbreakable string (URL, email, token with no spaces) overflows | `overflow-wrap: anywhere` on the parent, or `word-break: break-word` |
| Image without `max-width: 100%` escapes its container | Declare `max-width: 100%; height: auto` on the image |
| `width: 100vw` inside a padded container (`100vw` includes scrollbar and parent padding) | Use `width: 100%` (which respects the parent), or compute the gap |
| Unstyled `<iframe>` or `<video>` defaults to the source's natural width | `max-width: 100%; width: 100%` |
| Table without `table-layout: fixed` sizes columns by content and one long cell stretches it | `table-layout: fixed; width: 100%; word-break: break-word` |
| Negative margin or transform (e.g. `margin-left: -2rem`) extends past the viewport on small screens | Clamp the negative margin to viewport-aware values, or remove on small breakpoints |
Verify:
- The page does not scroll horizontally at 320, 375, and 425 CSS pixels wide.
- The geometry sweep returns zero `viewport-bleed` issues.
- No element's `scrollWidth` exceeds `document.scrollingElement.clientWidth`.
## Focus trap leak
Symptom: a modal/dialog is open but Tab moves focus out to background content, and the screen reader reads the background as well as the dialog.
Reproduce:
- Open the dialog, press Tab repeatedly, and confirm focus eventually lands on a background element (page header, an article-body link, a footer button).
- Note whether focus reaches the dialog's close button: if yes the trap is partial (escape only), if no the trap is absent.
Isolate:
- In the Elements panel find the node marked `aria-modal="true"` (or the dialog component root).
- Find the background node marked `inert` or `aria-hidden="true"`; if neither marker is present no trap is in place (absent-trap case).
- Enumerate the tab order; if background elements appear while the dialog is open, the inert boundary is wrong.
```js
const focusable = "a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex='-1'])";
[...document.querySelectorAll(focusable)]
.filter(el => el.offsetParent !== null)
.map(el => ({ tag: el.tagName, text: (el.textContent || el.value || "").trim().slice(0, 40) }))
.forEach(row => console.log(row));
```
Hypothesise (cause -> fix, ranked):
| Cause | Fix |
| --- | --- |
| `inert` applied to the wrong ancestor (marked `<main>` inert but header/footer stay interactive) | Mark every sibling of the dialog inert, not just one |
| The dialog is a descendant of the inert region (marking `body` inert traps the dialog too) | Render the dialog outside the inert subtree, or remove it from the inert subtree when it opens |
| `contenteditable` inside the dialog (a rich-text editor steals focus from the trap library) | Detect editable nodes and add them explicitly to the trap's allow-list |
| A third-party widget (toast, tooltip, autocomplete popover) renders as a portal at body level | Mark the third-party container inert when the dialog opens, or render dialog and third party in the same portal root |
| The trap library disabled Tab interception during a focus event | Subscribe to `focusin` at the document level and bounce focus back if it leaves the dialog |
| A native `<dialog>` used with `show()` instead of `showModal()` skips the platform's focus trap | Use `showModal()` |
Verify:
- Tab through the open dialog at least twice the number of focusable elements; focus must cycle within the dialog and never reach background content.
- Shift+Tab from the first focusable element returns to the last, not to the background.
- Esc closes the dialog and returns focus to the trigger.
- A screen reader reading "from the top" while the dialog is open must read only the dialog content.
## Lighthouse flake triage
Symptom: two runs against the same URL score wildly differently (99 then 85) and the team cannot agree whether the page passed.
Reproduce:
- Run Lighthouse five times in a row from the same machine (cache disabled, private window, same network); if the spread is greater than five points across the five runs, the page or run environment is flaky.
- Note which audit category drives the spread; Performance is the usual suspect while Accessibility, Best Practices, and SEO are deterministic.
Isolate:
- Throttling settle time: run the first Lighthouse pass after a 5-second warm-up and do not score it.
- Hot vs cold cache: a Service Worker or stale `localStorage` flag can survive a cold cache; open Application > Clear storage before each run.
- Network variability from Wi-Fi, VPN, or a flaky upstream produces variance Lighthouse cannot smooth; run from a wired connection or a CI runner.
- Block third parties (Request blocking or a `--block-url-patterns` flag) to see if the score stabilises, since analytics or A/B tests ship different code per run.
- Disable auto-play animations/videos for the run, since continuous activity holds the main thread and Lighthouse measures the activity rather than the steady-state page.
Hypothesise (cause -> fix, ranked):
| Cause | Fix |
| --- | --- |
| Third-party scripts vary per run (different ads, A/B buckets, feature flags) producing a score difference greater than five points across runs | Block third parties |
| The page does not reach a stable state (a polling timer, animation, or live region keeps the main thread busy) so Lighthouse never sees the quiet period | Remove or pause the continuous activity for the run |
| The runner is shared (a CI machine running multiple jobs introduces variance) | Pin the runner type or run in a sandbox |
| The page depends on time of day (a cron, daily refresh, or market-hours conditional renders different content) | Pin the request time |
Recipe: errors-in-console flake (third-party beacon discriminator). Symptom: the Best Practices `errors-in-console` audit fails intermittently across runs. Two hypotheses, separated by a headless reproduce (run from a headless browser of your choice, Puppeteer, Playwright, or equivalent, capturing `console` errors on a clean profile):
- Hypothesis A (false positive, a client blocker): the console error only appears when a content/ad blocker or extension cancels a third-party beacon request. Check: a clean headless profile with no extensions reproduces NO error. Then it is a client-side blocker artefact, not a page defect; document it and exclude that origin.
- Hypothesis B (real bug, stale SRI auto-injection): a third party auto-injects a script whose integrity hash drifted, so the browser rejects it (a stale SRI beacon). Check: the clean headless profile DOES reproduce the integrity/SRI error every run. Then it is a real defect: pin or refresh the integrity hash when you control the injection point; when an edge platform auto-injects the tag and you cannot control the hash, self-inject the plain vendor tag (no integrity, no crossorigin) so the auto-injector skips its broken pinned one (see lighthouse.md and security.md for SRI).
Median-of-three workflow: run Lighthouse three times back-to-back, report the median score per audit category, and reject any individual run scoring more than 10 points off the median (likely a network or third-party spike). For the production-build requirement and the phantom-failure rule (a dev build inflating or deflating scores), see lighthouse.md.
Verify:
- Three consecutive runs land within five points of each other on every audit category.
- The median score meets the project's bar and CI runs the median-of-three workflow recording the median, not the single run.
## Font-swap CLS
Symptom: FCP and LCP are fine but CLS is above 0.1, with the shift happening after first paint as text reflows when the webfont arrives. The CLS row points at the heading or body text node.
Reproduce:
- Throttle the network to "Slow 4G" in DevTools so the font swap is visible to the eye, then reload with cache disabled and watch the fallback font paint, the webfont swap in, and lines reflow.
Isolate:
- Confirm `font-display: swap` is in use (not `block`, which delays paint instead of shifting it); switching `block` to `swap` may move the cost to FCP, so choose deliberately.
- Identify the fallback font (next entry in the `font-family` stack): macOS often system serif/sans, Windows Segoe UI or Arial, Linux varies.
- Measure both fonts' metrics with a font inspection tool (FontDrop, fontview, or the Browser Font panel): ascent, descent, line gap, x-height, cap height.
Hypothesise (cause -> fix, ranked):
| Cause | Fix |
| --- | --- |
| Fallback metrics differ from webfont metrics so the line-box height changes on arrival | Declare a `@font-face` for the fallback with metrics-override descriptors |
| The webfont is variable but loaded as multiple files so a different weight loads and shifts | Use one variable font file and tune the metrics-override for the typical weight |
| `size-adjust` not set (two fonts at the same `font-size` render at different visual sizes) | Measure the x-height ratio and apply `size-adjust` |
Metrics-override workflow:
- Download both the webfont and chosen fallback (actual files, not OS reference), open in a font inspector, and record `unitsPerEm`, `ascent`, `descent`, `lineGap`, `xHeight`, `capHeight` for each.
- Compute the four override values:
- `ascent-override` = `(webfont.ascent / webfont.unitsPerEm) / (fallback.ascent / fallback.unitsPerEm) * 100%`
- `descent-override` = same ratio for descent
- `line-gap-override` = same ratio for line gap
- `size-adjust` = `(webfont.xHeight / webfont.unitsPerEm) / (fallback.xHeight / fallback.unitsPerEm) * 100%`
- Declare a `@font-face` for the fallback with these overrides:
```css
@font-face {
font-family: "Webfont Fallback";
src: local("Arial");
ascent-override: 92.5%;
descent-override: 24.3%;
line-gap-override: 0%;
size-adjust: 105.7%;
}
:root {
font-family: "Webfont", "Webfont Fallback", sans-serif;
}
```
The fallback now occupies the same visual box as the webfont; the swap does not shift.
Verify:
- With network throttled to Slow 4G, reload three times; the visible reflow on swap disappears.
- Lighthouse CLS for the affected page drops under 0.1.
- The Performance recording's Experience lane shows no shift after FCP for any text node.
## See Also
- [defects.md](defects.md) for the symptom-to-fix lookup and the geometry sweep
- [performance.md](performance.md) for the LCP, INP, CLS depth treatments these recipes assume
- [observability.md](observability.md) for the RUM, source-maps, and INP-attribution wiring that makes field reproduction possible
- [lighthouse.md](lighthouse.md) for the production-build requirement, the phantom-failure rule, and Lighthouse audit-id depth
references/accessibility.md
---
title: Accessibility Mastery
purpose: WCAG 2.2 AA discipline with semantic HTML, ARIA name patterns, keyboard and screen-reader flows, forced-colors mode, live regions, accessible drag-and-drop, and combobox/listbox APG markup. Verification stack is Lighthouse plus axe plus manual keyboard plus screen reader.
load-when:
task-keywords: [accessibility, a11y, WCAG, ARIA, screen reader, keyboard, focus, contrast, semantic HTML, forced colors, reduced motion, axe]
symptoms: [focus not visible, contrast fail, aria-hidden leak, inert leak, focus trap leak, duplicate id]
prereq: SKILL.md
related: [forms.md, motion.md, ui-ux.md, responsive.md]
size: ~477 lines
---
# Accessibility Mastery
WCAG 2.2 AA is the working bar, AAA where reasonable. Verification stack: Lighthouse plus axe plus manual keyboard plus screen reader. Framework-agnostic.
## The Four POUR Principles (WCAG)
- Perceivable: information must be presentable in ways users can perceive.
- Operable: interface components must be operable.
- Understandable: information and operation must be understandable.
- Robust: content must be robust enough to be interpreted by current and future user agents including assistive technologies.
## WCAG 2.2 New Success Criteria
The five most-missed of the nine added in WCAG 2.2:
| Criterion | Level | Rule and check |
|-----------|-------|----------------|
| 2.4.11 Focus Not Obscured (Minimum) | AA | A control receiving keyboard focus must not be entirely hidden by author content (sticky header/footer, consent overlay). Fix with `scroll-padding-top` matching sticky header height, or a layout gutter for the banner. Verify by tabbing the page with both a sticky header and a cookie banner visible: the focus ring must stay fully visible at every stop. |
| 2.5.7 Dragging Movements | AA | Every drag interaction needs a single-pointer alternative (button, keyboard shortcut, or tap-to-pick/tap-to-drop). Applies to slider drags, draggable list reorders, kanban moves, map pans. See "Accessible Drag-and-Drop" below. |
| 3.2.6 Consistent Help | A | A help mechanism (contact link, chat, FAQ search) appears in the same relative order on every page where it appears. |
| 3.3.7 Redundant Entry | A | Info the user already entered in the same session must be auto-filled or re-pulled (no retyping address across shipping and billing). Exceptions: re-entry is essential, the info changed, or the previous answer is no longer valid. |
| 3.3.8 Accessible Authentication (Minimum) | AA | No cognitive function test (memorising a password, recognising specific characters, transcribing a CAPTCHA) without an alternative. Passkeys, OAuth, magic links, email/SMS codes, and password-manager autofill all qualify. See auth.md. |
## Forced-Colors Mode
Forced-colors environments override author colors with a system palette, exposed via the `forced-colors` media query.
- Use system-color keywords for any shipped color: `Canvas` (page background), `CanvasText` (body text), `LinkText` (links), `Highlight` (selected text background), `HighlightText` (selected text foreground), `ButtonText`, `ButtonFace`, `GrayText` (disabled text).
- `forced-color-adjust: none` opts an element out of the system palette. Use only for genuinely meaningful colors (brand logo, chart legend swatch, status indicator) with verified contrast against `Canvas`. Default to `auto` everywhere else.
- Fix an SVG icon that flattens to currentColor: set `fill: currentColor` plus `forced-color-adjust: auto`.
- Fix a custom focus ring invisible in forced-colors: add a forced-colors fallback that uses `Highlight` (`outline: 2px solid Highlight; outline-offset: 2px`).
```css
@media (forced-colors: active) {
.focus-ring:focus-visible {
outline: 2px solid Highlight;
outline-offset: 2px;
}
.icon { fill: CanvasText; }
.icon-link { fill: LinkText; }
}
```
Verify in a Chromium browser via DevTools Rendering, Emulate CSS media feature, `forced-colors: active`.
## Semantic HTML First
ARIA is the polyfill, semantics are the standard. The native element gives keyboard, focus, semantics, and screen-reader support automatically.
| Use this | Not this |
|----------|----------|
| `<button>` | `<div onclick>` |
| `<a href>` | `<span onclick>` (for navigation) |
| `<nav>` | `<div class="nav">` |
| `<main>` | `<div id="main">` |
| `<header>` / `<footer>` | `<div class="header">` |
| `<section>` with heading | bare `<div>` |
| `<article>` for self-contained content | `<div>` |
| `<aside>` for tangentially related | `<div class="sidebar">` |
| `<form>` with `<label>` | `<div>` with floating placeholder |
| `<input type="email">` | `<input type="text" pattern>` |
| `<details>`/`<summary>` | div + click handler |
| `<dialog>` (HTMLDialogElement API) | div with role="dialog" + custom focus trap |
| `<table>` for tabular data | div grid |
| `<ul>`/`<ol>` for lists | div with line breaks |
| `<time datetime>` | `<span>` |
| `<address>` for contact info | `<div>` |
## ARIA Rules
The five rules:
1. If you can use a native element, use the native element.
2. Don't change native semantics unless necessary (`<button role="link">` is suspicious).
3. All interactive ARIA elements must be keyboard-accessible.
4. Don't use `role="presentation"` or `aria-hidden="true"` on a focusable element.
5. All interactive elements must have an accessible name.
### Accessible name source per pattern
| Pattern | Accessible name |
|---------|----------------|
| Button with text | The text content |
| Icon-only button | `aria-label="..."` |
| Link with text | The text content |
| Link with image only | `<img alt="...">` or `aria-label` on the link |
| Input | `<label for="id">` or `aria-labelledby="id"` or (last resort) `aria-label` |
| Disclosure | `aria-expanded="true"`/`"false"` on the trigger |
| Tab | `role="tab"`, `aria-selected`, `aria-controls` referencing the panel |
| Modal | `role="dialog"`, `aria-modal="true"`, `aria-labelledby` referencing the title |
| Tooltip | `aria-describedby` on the trigger pointing to the tooltip |
| Live region | `aria-live="polite"` (or `assertive` for urgent), `role="status"` for non-interrupting |
| Alert | `role="alert"` (implies `aria-live="assertive"`) |
| Listbox | `role="listbox"`, `aria-activedescendant` for the current option |
| Combobox | `role="combobox"`, `aria-expanded`, `aria-controls`, `aria-autocomplete` |
| Meaningful inline SVG (chart, diagram, informative icon) | `role="img"` PLUS a name (a `<title>` element or `aria-label`) |
| Decorative inline SVG | `aria-hidden="true"` |
Inline-SVG trap: a meaningful inline SVG needs `role="img"` AND an accessible name; a decorative one needs `aria-hidden="true"`. Adding `role="img"` WITHOUT a name is worse than hiding it: it exposes a nameless image to assistive tech and can trip image-alt, dropping both Accessibility and SEO. Check: every inline SVG is either `aria-hidden="true"` or `role="img"` with a name; no inline SVG has a role and no name.
### Combobox and Listbox (ARIA APG 1.2)
APG 1.2 recognises three valid combobox markups. Pick one and apply it end-to-end; do not blend.
1. Editable text input + popup listbox: `<input role="combobox" aria-expanded aria-controls="listbox-id" aria-activedescendant="option-id">` paired with a sibling `<ul role="listbox" id="listbox-id">`. The popup may be a listbox, grid, tree, or dialog, declared via the `aria-controls` target's role.
2. Editable text input + inline popup (chips, badges): same `role="combobox"` on the input, but the popup is inline rather than overlay.
3. Without a textbox (select replacement): `<button role="combobox" aria-expanded aria-controls aria-haspopup="listbox">` plus the listbox. Use when the user picks from a fixed list and does not type.
`aria-autocomplete` declares the behaviour:
| Value | Behaviour |
|-------|-----------|
| `none` | No autocomplete; the popup may still suggest. |
| `list` | The popup filters to matches as the user types. |
| `both` | The popup filters AND the input value is autocompleted inline (the unmatched suffix is selected so the next keystroke replaces it). |
| `inline` | The input value is autocompleted inline, no popup filtering. |
Keyboard model, pick one:
- Active-descendant (preferred for combobox): DOM focus stays on the input; the active option is marked via `aria-activedescendant="option-id"` on the input; arrow keys move it; Enter commits its value; the SR announces the active descendant.
- Focus-roving: DOM focus moves to the active option inside the listbox and the input loses focus. Older pattern that breaks the combobox model because the input no longer receives keystrokes.
Common defects: `aria-expanded` not flipping when the popup opens; `aria-activedescendant` pointing to a changed or nonexistent id; `role="combobox"` on the wrapping `<div>` instead of the input or button.
### Label vs labelledby vs describedby
- `aria-label`: a short string. Use only when no visible text exists.
- `aria-labelledby`: references existing on-page text by id. Preferred over `aria-label` because it stays in sync with visible UI.
- `aria-describedby`: supplemental description (helper text, error message). Announced after the label.
## Color and Contrast
### Targets
| Element | AA minimum | AAA |
|---------|-----------|-----|
| Body text (< 18px regular or < 14px bold) | 4.5:1 | 7:1 |
| Large text (>= 18px regular or >= 14px bold) | 3:1 | 4.5:1 |
| UI components and graphical objects (meaningful icons, focus rings, form borders) | 3:1 | n/a |
### Verification
- Use the browser DevTools color picker to see the live contrast ratio for the current foreground vs computed background.
- Test in light AND dark mode independently; inverting a palette rarely preserves contrast.
- Test with `prefers-contrast: more` if your design system supports it.
### Traps
- `text-slate-400` on a white background fails 4.5:1.
- `text-slate-400` on `bg-slate-950` passes; on `bg-slate-900` it is marginal.
- Placeholder text (`color: gray`) often fails. Treat placeholders as decorative; never put critical info there.
- Disabled state contrast does not need to meet 4.5:1 but should still be perceivable.
- Brand colors over photographic backgrounds need a scrim or darkening overlay.
### Color is never the only signal
Pair color with text, icon, or pattern:
- Required field: red asterisk + the word "required" in helper text.
- Error: red border + error icon + error message.
- Status: colored dot + status word.
- Chart series: color + pattern/dash + label.
## Keyboard
Every interactive element must be:
- Reachable by Tab in a logical order.
- Operable by Enter, Space, or arrow keys per the WAI-ARIA Authoring Practices.
- Visible when focused, with a focus ring at 3:1 contrast against the surface.
### Tab order
- Use natural document order; avoid `tabindex` > 0 (it overrides natural order and creates surprising flows).
- `tabindex="0"` makes a non-focusable element focusable.
- `tabindex="-1"` makes an element programmatically focusable (for moving focus into a modal) but not in tab order.
### Keys per pattern
| Pattern | Keys |
|---------|------|
| Button | Enter or Space |
| Link | Enter |
| Checkbox | Space |
| Radio group | Arrow keys move within group, Tab moves out |
| Tabs | Arrow keys move between tabs (if focus follows), Tab moves to panel |
| Combobox | Arrow keys, Enter to select, Esc to close |
| Menu / menubar | Arrow keys, Enter to activate, Esc to close |
| Modal | Esc to close, Tab loops within, focus restored on close |
| Slider | Arrow keys for fine, Page Up/Down for coarse, Home/End for min/max |
| Tree | Arrow keys, Enter/Space to activate, Right to expand, Left to collapse |
### Focus management
- Modal open: move focus to the modal (close button or first form field), save the previously-focused element, restore focus to it on close.
- SPA route change: move focus to the main heading or to a `<main tabindex="-1">` so screen readers announce the new page.
- Form errors: move focus to the first invalid field (or to a top summary with anchor links to each error).
- Form success: move focus to the success message and announce it via `role="status"`.
### Focus visibility
- Never `outline: none` without a replacement.
- Use `:focus-visible` (not `:focus`) so mouse users don't see a ring when clicking but keyboard users always do.
- Focus ring: 2-4px, contrast 3:1 against both the surface and the resting state of the element.
## Screen Reader
Test on at least one combination:
- VoiceOver on macOS/iOS (Cmd+F5 to toggle).
- NVDA on Windows (free).
- TalkBack on Android.
- JAWS on Windows (paid; only test if your audience needs it).
Verify:
- Page structure announced correctly: headings, landmarks (`main`, `nav`, `aside`, `footer`), section labels.
- Reading order matches visual order top-to-bottom.
- Interactive controls have names and roles announced ("Submit, button" not "Submit, clickable element").
- State changes are announced (adding an item to a cart announces the new state via live region or focus move).
- Errors are announced via `role="alert"` or `aria-live="assertive"` for blocking errors, `aria-live="polite"` for advisories.
### aria-live regions
- `aria-live="polite"` for non-blocking updates (toast, sync status, autosave); announced when the SR finishes its current utterance.
- `aria-live="assertive"` for blocking errors only; interrupts the SR.
- `role="status"` is `aria-live="polite"` + `aria-atomic="true"`.
- `role="alert"` is `aria-live="assertive"` + `aria-atomic="true"`.
- The element must be present in the DOM at page load for SRs to monitor it; inserting it later sometimes fails.
### Live-region timing
- Debounce rapid updates: announce at most once per 250 ms. Wrap faster-changing values (search results count, slider value, character count) in a debouncer that coalesces intermediate states and flushes the final value.
- `aria-atomic="false"` for diff reads on long status text where only a small portion changes (counter inside a paragraph, timestamp at end of sentence). Default `aria-atomic="true"` is correct only for short status strings.
- `aria-relevant="additions text"` (default) announces inserted nodes and text changes; `"all"` adds removals (chat "user left"); `"additions"` alone suppresses text-only edits.
- One region per concern: two competing assertive regions race and the SR drops the loser. Use one assertive region for blocking errors, one polite region for status.
## Heading Hierarchy
- One `<h1>` per page (the page's primary intent).
- Sequential descent H1 -> H2 -> H3; never skip a level.
- Headings describe sections; not used for typography.
- Section landmarks should be labeled by their heading: `<section aria-labelledby="hero-title"><h2 id="hero-title">...</h2></section>`.
## Forms
Every input has a programmatic label:
```html
<!-- Best -->
<label for="email">Email</label>
<input id="email" type="email" autocomplete="email" required />
<!-- Acceptable when wrapping is impractical -->
<label>Email <input type="email" /></label>
<!-- Last resort -->
<input type="email" aria-label="Email" />
```
`placeholder` is not a label; placeholders disappear when the user types and have low contrast by default.
Input attributes:
- `type` matching the data (`email`, `tel`, `url`, `number`, `search`) affects mobile keyboards and validation.
- `autocomplete` per the WHATWG list (`given-name`, `email`, `street-address`, `cc-number`, `one-time-code`, etc.) for mobile UX and password managers.
- `inputmode` overrides the keyboard without changing semantics: `numeric` for codes, `decimal` for prices.
- `required`, `min`, `max`, `pattern` for native validation.
- `aria-invalid="true"` when invalid; `aria-describedby` pointing to the error message.
Error handling:
- Show errors inline, near the field.
- Use `role="alert"` or `aria-live="polite"` so SRs announce them.
- The error message must say cause AND fix ("Email is required" is OK; "Enter your work email" is better).
- For multi-error submission show a summary at the top with anchor links to each invalid field.
- After a submit error move focus to the first invalid field.
See forms.md for the deep dive.
## Images and Media
### Alt text
- Decorative image: `alt=""` (empty string is required; missing alt is worse).
- Functional image (icon button): describe the action not the image, e.g. `<button><img alt="Close" src="x.svg" /></button>`.
- Informational image: describe what conveys information; concise, prefer 8-12 words.
- Complex image (chart, diagram): short alt + long description nearby (`figure` + `figcaption` or `aria-describedby` to a hidden description).
- Logo image: `alt="Brand"` not `alt="Brand logo"` (the word "logo" is redundant).
- CSS background images should be decorative only; anything informational should be an `<img>`.
### Inline SVG (decorative vs meaningful)
- Meaningful inline SVG (chart, diagram, informative icon): `role="img"` PLUS an accessible name (a `<title>` element or `aria-label`).
- Decorative inline SVG: `aria-hidden="true"`.
- Never add `role="img"` without a name: it exposes a nameless image to assistive tech, can trip image-alt, and drops both Accessibility and SEO. Worse than hiding it.
- Check: every inline SVG is either `aria-hidden="true"` or `role="img"` with a name; no inline SVG has a role and no name.
### Video
- Provide captions (`<track kind="captions">`) for any spoken content.
- Provide a transcript for long-form video.
- Provide audio description for video where visual information is critical.
- Don't autoplay video with sound.
- Don't autoplay-loop a hero video without a pause control.
### Audio
- Provide a transcript.
- Don't autoplay audio.
## Motion and Reduced Motion
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
```
This is the floor. Better practice: actually remove non-essential animation rather than just shortening it. For essential motion (loading spinner, video playback) keep it, but consider whether a static skeleton suffices or whether the user can pause. See motion.md.
## Dynamic Type / Zoom
- Use relative units (`rem`, `em`, `%`) for font size, line height, padding; avoid `px` for type.
- Test at 200% browser zoom: layout should remain usable with no horizontal scroll and no clipped content.
- Test with `font-size: 24px` set on `<html>`: components should scale.
- Don't disable user zoom; `user-scalable=no` and `maximum-scale=1` are forbidden.
## Touch and Hit Targets
- Minimum touch target 44x44 CSS pixels (iOS HIG) or 48x48 dp (Material Design).
- Minimum 8 px spacing between adjacent targets.
- For small icons, expand the hit area with padding or `::before` extension while keeping the visual size small.
- Apply the 44px (or 48px) minimum to standalone controls: buttons, toggles, menu triggers, icon buttons, form controls, CTA links.
- WCAG 2.5.8 (target size, AA, 24px): inline sentence links, breadcrumb trails, and footer text-link lists are exempt from the larger target size. Give them a small comfortable hit area (a little vertical padding); do not inflate to 44px tall (a 44px breadcrumb row or in-prose link reads as broken).
## Language
- `<html lang="en">` (or your locale, e.g. `lang="en-US"`, `lang="ja"`).
- `lang` on inline elements when language changes mid-content: `<span lang="fr">déjà vu</span>`.
- `dir="rtl"` on `<html>` for right-to-left languages, with logical properties (`margin-inline-start`) so layout adapts. See i18n.md.
## Lang, Main, and Skip Link
Three structural basics every route must satisfy (checked in the multi-page audit, see audit-workflow.md):
- [ ] `<html lang>` is present and valid (e.g. `lang="en"`, `lang="en-US"`, `lang="ja"`).
- [ ] Exactly one primary `<main>` per route; nested `<main>` elements break landmark navigation.
- [ ] A skip link is the first focusable element on the page, hidden until focused, jumps to `<main>`, and does not create viewport overflow while hidden.
The skip-link no-overflow requirement is the most-skipped: the `position: absolute; left: -9999px` pattern works only when paired with a focus state that brings the link back on-screen without growing the page. Use the `.sr-only-focusable` pattern below.
Progressive enhancement: a JS-driven mobile menu must leave its links reachable without JavaScript (a `<noscript>` fallback nav or static links that enhance). See responsive.md.
## Skip Links
```html
<a href="#main" class="sr-only-focusable">Skip to main content</a>
...
<main id="main" tabindex="-1">...</main>
```
```css
.sr-only-focusable {
position: absolute;
left: -9999px;
}
.sr-only-focusable:focus {
position: fixed;
top: 1rem;
left: 1rem;
z-index: 1000;
padding: 0.5rem 1rem;
background: white;
color: black;
border: 2px solid currentColor;
}
```
## Modal and Dialog
### Native dialog (preferred)
```html
<dialog id="confirm">
<h2>Confirm action</h2>
<p>Are you sure?</p>
<form method="dialog">
<button value="cancel">Cancel</button>
<button value="confirm">Confirm</button>
</form>
</dialog>
<button onclick="document.getElementById('confirm').showModal()">Open</button>
```
`<dialog>` with `showModal()` provides auto focus management (focus moves into the dialog), Esc to close, a stylable backdrop (`::backdrop`), and inert background (modal pattern).
### Custom modal
If you must build one (rich content, framework-managed):
- `role="dialog"` (or `alertdialog` for blocking errors), `aria-modal="true"`.
- `aria-labelledby` pointing to the title.
- Focus trap: Tab and Shift+Tab loop within the modal.
- Esc closes.
- On open, focus moves to the modal (close button or first field).
- On close, focus returns to the trigger.
- Background is `inert` (or has `aria-hidden="true"` and `tabindex="-1"` plus `pointer-events: none`).
## Accessible Drag-and-Drop
WCAG 2.5.7 requires every drag interaction to have a single-pointer alternative. The canonical keyboard pattern for a reorderable list (or kanban, or sortable grid) is pick up, move, drop, driven by Enter, arrows, and Esc.
- Make each item a button or `tabindex="0"` listitem so it receives keyboard focus directly; a draggable `<div>` with no role is not reachable.
- Enter (or Space) on a focused item enters grab mode: set `aria-grabbed="true"` (deprecated in ARIA 1.2 but still announced by some SRs) or, preferred, push to a live region: "Picked up Item A. Use arrow keys to move, Enter to drop, Esc to cancel."
- Arrow keys move the item up/down within the list (or across kanban columns); each move announces the new position via the live region: "Item A, moved to position 3 of 7."
- Enter (or Space) again drops the item at its current position: "Dropped Item A at position 3."
- Esc cancels the drag and restores the original position: "Cancelled. Item A returned to position 1."
- The pointer drag interaction stays available for users who prefer it; both paths land at the same end-state mutation.
- Touch alternative for touch-only users without a keyboard: long-press to grab, tap to drop, or a per-row "Move" button opening a dialog with arrow buttons.
The live region drives the entire experience for SR users; a drag-and-drop without that region is functionally inaccessible even if the keyboard works.
## Common Accessibility Mistakes
- `<div onClick>` instead of `<button>`: loses keyboard, focus, semantics.
- `<a href="#">` for buttons: use `<button>`.
- `placeholder` as label: disappears when typing.
- `aria-label` on a button that already has visible text: overrides the visible text.
- `aria-hidden="true"` on a focusable element: creates a "ghost" focus.
- `outline: none` without a replacement: removes keyboard focus indication.
- Custom dropdown without arrow-key support.
- Modal without focus management.
- Toast that announces every minor event with `aria-live="assertive"`: use `polite`.
- Loading spinner without an accessible name: add `aria-label="Loading"` on the spinner element.
- Drag-and-drop without a keyboard alternative.
## Self-Healing for Accessibility
After any change, verify:
- [ ] Lighthouse Accessibility = 100
- [ ] axe DevTools shows zero violations
- [ ] Tab through the full page; everything reachable, no traps
- [ ] Esc closes any open modal/menu
- [ ] Screen reader pass on the primary user flow (VO or NVDA)
- [ ] Light AND dark mode contrast checked separately
- [ ] 200% browser zoom: layout intact, no horizontal scroll
- [ ] `prefers-reduced-motion` respected
- [ ] Forms: every input labeled, errors announced, focus moves to first invalid field on submit
- [ ] Modals: focus trapped, Esc closes, focus restored on close
- [ ] Images: alt text present, decorative images have `alt=""`
- [ ] Headings: one H1, sequential, no skipped levels
- [ ] Color is never the only signal
## See Also
- [forms.md](forms.md) for form-specific accessibility
- [motion.md](motion.md) for reduced-motion implementation
- [ui-ux.md](ui-ux.md) for state and interaction patterns
- [responsive.md](responsive.md) for the JS-free mobile nav requirement
references/components.md
---
title: Component Discipline
purpose: Standardize repeated widgets across pages behind one canonical contract, compose with slots over prop explosion, and detect drift programmatically before it ships.
load-when:
task-keywords: [component, widget, contract, extraction, slots, composition, Storybook, tokens, design]
symptoms: [duplicate id, score dropped, viewport overflow, dark mode broken]
prereq: SKILL.md
related: [audit-workflow.md, defects.md, design.md, ui-ux.md]
size: ~283 lines
---
# Component Discipline
Polishing each instance separately is not enough: same-purpose widgets must follow one canonical visual and content contract. A repeated widget either has one source of truth or one documented, named variant. There is no third option.
## Terms
- Widget family: a group of repeated UI elements serving the same role across pages (all feature cards, all CTA bands, all hero variants). See the 11-family table.
- Canonical contract: the single authoritative visual + content spec for a family. Names required/optional content fields, allowed variants, the family CSS properties (padding, radius, shadow), and states (hover, focus, active, disabled, loading). A component is not done until the contract is documented and every instance matches it.
- Named variant: an intentional, documented variation exposed through one prop, one class, or one data attribute (`card--feature`, `card--integration`, `card--pricing`). Ad hoc class combinations are not variants, they are drift.
- Drift: a repeated widget that looks or behaves differently across pages without a named variant. Signals a missing abstraction or incomplete variant set. Detected by visual comparison and the drift collector.
- Drift collector: a browser-automation script that reads computed styles and bounding boxes for every family member across every audited route, then flags any difference not explained by a named variant.
## When to Extract
Extract repeated markup into a single source of truth when:
- The same structure appears three or more times across routes.
- The same structure appears twice with clear future reuse.
- Duplicated markup causes visible drift (different padding, radius, typography, or hover behavior on instances that should look identical).
Do not extract when:
- The local stack has no include or component mechanism and adding one adds build complexity out of proportion to the benefit. Normalize markup and class names instead.
- Two instances look similar but serve genuinely different semantic purposes. Build them as named variants, not one flexing component.
Sequencing rules:
- Extract DATA before markup. The first duplication to remove is shared data, not structure: a value used in two or more places (domain/base URL, price, label, disclaimer, magic number, enum) belongs in one module every call site imports. Copied strings/numbers diverge silently.
- Know when to STOP. A duplicate that varies by exactly one value is a clean component with one prop. A near-duplicate needing two or more shape/layout props is a premature abstraction (it pushes styling back to the call site, the over-generic anti-pattern); leave contextually-distinct near-duplicates inline and note them.
## The 11 Widget Families
Every multi-page polish pass starts by inventorying these families. The inventory is mandatory: without it, drift is invisible and standardization is guesswork (see [audit-workflow.md](audit-workflow.md) Phase 3). For each family the inventory records: routes where it appears, source (template/component/duplicated markup), required and optional content fields, allowed variants, and the canonical visual contract (spacing, typography, border, radius, shadow, color, icon placement, image ratio, CTA behavior, focus ring, hover behavior, responsive behavior).
| Family | Members | Standardization criteria |
|--------|---------|--------------------------|
| Global shell | Header, primary nav, dropdowns, mobile drawer, footer, legal/social row | One header, one footer, one drawer; consistent across every route |
| Hero systems | Centered, asymmetric, product, landing, legal/simple | Each hero variant is a named component; no ad-hoc combinations |
| Section headers | Eyebrow/label, heading, subheading | One spacing rule, one max-width rule, one alignment rule per variant |
| CTA systems | Primary button, secondary button, text link, final CTA band, pricing CTA, card CTA | One size scale, one radius scale, one focus ring, one hover transform |
| Card systems | Feature, benefit, outcome, pain, directory, integration, pricing, FAQ, legal-content | Equal heights in rows, consistent padding, consistent CTA placement |
| Media systems | Image rows, logo tiles, screenshots, illustrations, icon boxes, avatars | Fixed image box dimensions per family, one alt-text policy, one loading strategy |
| Data/list systems | Pricing tables, comparison tables, FAQ lists, pain lists, checklists, icon lists | Tabular figures for numbers, one bullet/icon style per list type |
| Interactive systems | Accordions, tabs, menus, dropdowns, drawers, modals, hover cards, forms | One open/close behavior, one focus management policy, one Esc/outside-click rule |
| Empty states | Empty list, empty search, zero data | Same polish as marketing surfaces; specific message and primary action |
| Legal pages | Terms, privacy, acceptable use, security | Shared layout shell; consistent typography and spacing |
| 404 / error pages | 404, 5xx, offline | Branded and useful (search box, home link), not just an apology |
## Component Contract Checklist
A widget is ready for extraction when every item is true:
- [ ] Required content fields documented by usage: title, body, eyebrow, image, CTA, secondary CTA, metadata, icon, badge.
- [ ] Optional fields have graceful empty states and leave no blank gaps.
- [ ] Markup is semantic and stable: headings use the right level, landmarks are correct, lists are real lists.
- [ ] Layout is resilient to long text, very short text, and missing optional fields. Test the longest realistic title and the shortest.
- [ ] Desktop and mobile behavior is defined explicitly, not by accident.
- [ ] Hover, focus, active, disabled, and loading states are defined when relevant.
- [ ] Accessible names come from visible content unless a different name is necessary; visible text and `aria-label` agree.
- [ ] Images have consistent intrinsic dimensions, aspect ratio, loading strategy, and alt behavior.
- [ ] No page-specific magic numbers inside the component unless exposed as a named variant.
- [ ] The component owns its CSS. Page-local overrides on shared widgets are forbidden.
- [ ] Variants are explicit (one prop, one class, one data attribute), not arbitrary class combinations.
- [ ] No hardcoded DOM `id` inside the component. Any `id` (and any `aria-controls`, `aria-labelledby`, `for`, or `href="#..."` that targets one) is derived from a per-instance unique id (`useId()` or the framework equivalent) and child ids are namespaced under it. A static id breaks the moment the component renders twice on a page.
## Server vs Client Boundary
State the boundary at the top of the contract. A component that tries to be both fails both.
| Boundary | Contract |
|----------|----------|
| Server | No event handlers, no client state, no browser-only APIs (`window`, `document`, `localStorage`, `IntersectionObserver`). Renders on the server (or build time), ships zero client JS for itself: data fetch, layout, semantic markup, the static skeleton. |
| Client | Interactive, hydrated, owns its event handlers and local state, ships the JS to drive its behaviour: a client island (modal, popover, tab strip, autocomplete). |
| Hybrid | A defect. A "server" component that calls a hook, attaches a listener, or reads `window` explodes in a server runtime or skips hydration silently. A "client" component doing no interactive work pays for hydration without earning it. |
- Declare the boundary in the contract header (a JSDoc tag, a top-of-file comment, or a framework-recognised directive).
- Lint or type-check rejects components that import a server-only module from a client boundary, or a browser-only API from a server boundary.
## Extraction Sequence
Follow in order; skipping a step turns extraction into another duplicated markup variant.
1. Choose the canonical contract from the best existing implementation, the reference target, and product needs. It is one design, not a union of all current usages.
2. Name the widget and variants clearly (`hero-asymmetric`, `section-heading`, `feature-card`, `integration-card`, `pricing-card`, `final-cta`, `alternating-row`, `faq-list`). Avoid generic names: `card`, `section`, `block`.
3. Move repeated markup into the framework's native component or partial mechanism (server-side partials, single-file components, JSX components, or build-time includes). Use the mechanism the project already uses.
4. Pass content as explicit fields. Embedded page-specific text inside shared markup is the most common cause of a component that still drifts.
5. Make variants explicit through one prop, one class, or one data attribute. If a variant needs three coordinated changes, encode them in one named variant, not three knobs.
6. Centralize the CSS for the family and remove/reduce page-local overrides. Page CSS adjusts only what the component exposes.
7. Re-render every route that uses the widget. A single broken instance proves the component is incomplete.
8. Compare every instance side-by-side at both audit viewports. If two instances do not look like members of the same system, the contract or variant set is wrong.
## Versioning and Breaking Changes
The canonical contract is an API; carry a semver, tell consumers their version, ship breaking changes with a migration path.
| Bump | Meaning |
|------|---------|
| Patch (`x.y.Z`) | Bug fix that does not change the contract (focus ring 2px to 3px for contrast; margin 16px to 24px to match the token). Consumers pick up the fix on next install, no code change. |
| Minor (`x.Y.0`) | Backward-compatible addition: a new optional prop, new named variant, new slot, additional state. Existing call sites keep working unchanged. |
| Major (`X.0.0`) | Removed prop, removed variant, renamed slot, changed default behaviour, changed required-vs-optional, or changed semantic markup affecting accessibility wiring. Existing call sites need an update. |
Deprecation discipline for any planned removal:
- Mark the prop, variant, or slot with `@deprecated` JSDoc and a short reason. Type checkers and editors surface the warning at every call site.
- Emit a one-line `console.warn` in development builds gated by `process.env.NODE_ENV !== "production"` (or the framework equivalent). Production stays silent; the warning names the deprecated thing and points at the migration.
- Link the migration codemod from the deprecation message itself, not from a separate changelog entry:
```text
[component] prop `size="huge"` is deprecated; use the `<header slot>` instead. Run `npx your-codemod component-size-huge` to migrate. Removed in v3.0.0.
```
- The codemod ships with the deprecation, not after the removal. A deprecation without a migration path is a future breakage.
## What To Avoid
These four anti-patterns produce most cross-page drift:
- Copy-pasting a polished widget and tweaking classes page by page. The first divergence starts the drift; every later page makes the contract harder to recover.
- Creating near-duplicate components with different names but the same semantic purpose. `feature-card`, `feature-tile`, and `benefit-card` that differ only in padding and CTA copy are one component with one variant axis.
- Fixing drift by adding more one-off CSS selectors. Page-local overrides reduce the symptom and reproduce the disease at the next site of drift.
- Making one component so generic that every call site needs overrides to look right. Generic components without strong defaults push the styling burden back to the page, where the drift lives.
Plus the `id` trap: hardcoding a DOM `id` (or an `aria-controls`/`aria-labelledby` target) inside a reusable component rendered more than once produces duplicate ids: invalid HTML, a Lighthouse `duplicate-id` (or axe `duplicate-id-aria`) failure, and broken ARIA wiring (a label or control points at the wrong instance). Generate a unique id per instance and namespace children under it.
## Slots and Composition Over Prop Explosion
The over-generic-component anti-pattern resolves to slots, not more props. Rule: if you would need three or more shape props (props that change WHICH elements render, not WHAT they contain), use a slot instead.
- A slot is `children` (the default slot), named slots like `<header>` and `<footer>` (web components, single-file frameworks), or render props (component-as-function patterns). Expose composition explicitly per framework.
- Reach for slots when the variation is structural: optional sections, optional decorations, optional adjacent content.
- Stay on props when the variation is data: title text, image URL, CTA label, variant name (one named variant prop, not a knob per CSS property).
- Composed components stay testable: each slot has a known contract; the parent need not know what fills it. Slots break the prop-explosion to drift cycle.
## Drift Detection
Programmatic detection complements visual review. For each repeated widget selector, collect:
- Width, height, padding, border-radius, border color, border width, border style, background, box-shadow.
- Heading font-size, weight, line-height.
- Body font-size, line-height, color.
- CTA position and size.
- Image and logo rendered size and aspect ratio.
- Grid row height variance across the row.
Run from a headless browser of your choice (Puppeteer, Playwright, or equivalent). The snippet uses only standard DOM and CSSOM APIs:
```js
const componentMetrics = await page.evaluate(() => {
const read = (el) => {
const cs = getComputedStyle(el);
const r = el.getBoundingClientRect();
const heading = el.querySelector("h2, h3, .card-title");
const body = el.querySelector("p");
const cta = el.querySelector("a, button, .btn, .btn-text");
const img = el.querySelector("img");
return {
selector: el.className,
width: Math.round(r.width),
height: Math.round(r.height),
padding: cs.padding,
radius: cs.borderRadius,
background: cs.backgroundColor,
borderColor: cs.borderColor,
borderWidth: cs.borderWidth,
borderStyle: cs.borderStyle,
shadow: cs.boxShadow,
headingSize: heading ? getComputedStyle(heading).fontSize : null,
headingWeight: heading ? getComputedStyle(heading).fontWeight : null,
bodySize: body ? getComputedStyle(body).fontSize : null,
bodyLine: body ? getComputedStyle(body).lineHeight : null,
cta: cta ? cta.getBoundingClientRect().toJSON() : null,
image: img ? img.getBoundingClientRect().toJSON() : null,
};
};
const families = {
cards: [".feature-card", ".card-link", ".grid-item"],
integrations: [".integration-card", ".integration-tile", ".integration-logo-tile"],
pricing: [".pricing-card", ".pricing-table"],
};
const out = {};
for (const [name, selectors] of Object.entries(families)) {
out[name] = selectors.flatMap((s) => [...document.querySelectorAll(s)].map(read));
}
return out;
});
```
Run the collector on every route in scope and compare each family across pages. Any difference in padding, radius, shadow, heading size, or CTA position not explained by a named variant is drift: fix the component (not the page) until the next run is clean. A family is drift-free when every same-variant instance produces the same metrics within rounding tolerance.
## CSS Patterns That Prevent Drift
Use these on every shared widget; they are the multi-instance subset. Layout primitives live in [responsive.md](responsive.md); color, typography, and shadow tokens in [design.md](design.md).
- Tokens before primitives: normalize spacing, color, border, radius, shadow, and type-scale tokens before building. A component referencing tokens stays in step; one that hardcodes values drifts.
- Stable dimensions for repeated UI: `min-height` on cards prevents short-content rows from collapsing; `aspect-ratio` on media boxes prevents image jitter; `align-items: stretch` on grid/flex rows keeps siblings the same height; fixed icon boxes (square wrapper around the SVG) prevent optical mis-centering.
Card grid pattern:
```css
.card-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px;
align-items: stretch;
}
.card-grid > * {
min-width: 0;
height: 100%;
}
.card-link {
display: flex;
flex-direction: column;
min-height: 100%;
text-decoration: none;
}
.card-link:focus-visible,
.button:focus-visible {
outline: 3px solid var(--focus-ring-fallback, #4f8cff);
outline: 3px solid color-mix(in srgb, var(--accent) 45%, white);
outline-offset: 3px;
}
@media (max-width: 767px) {
.card-grid {
grid-template-columns: 1fr;
gap: 16px;
}
}
```
- `min-width: 0` on grid children is the most-skipped guard against text overflow. Without it, long words break out of the cell and drag the page wide on mobile.
- `color-mix(in srgb, ...)` is Baseline 2024. Always provide the static-color `outline` line first as the fallback. If the project supports pre-2024 browsers, drop the `color-mix` line and use a precomputed token (`var(--color-focus-ring)`).
- Spacing via `gap`, not margin stacks: `gap` is owned by the container and stays constant; margin stacks accumulate across instances and produce per-page drift.
- Line length via `max-inline-size: 65ch` (or similar). Cards without measure constraints produce 90-character lines on desktop and 30-character lines on mobile.
- Full-card anchor, not nested anchors: when a card visually behaves as a link, the entire card is the anchor. Nested anchors are invalid HTML. For an inner CTA distinct from the outer link, switch to the button-card pattern: a stretched `::before` pseudo-element on the title link makes the whole card clickable while keeping nested controls separate.
- Hover transforms do not stack: if the card animates on hover and an inner CTA also animates, the two transforms compose into a buggy shift. Pick one layer for the hover transform and suppress the other inside the animated container.
- `:focus-visible`, not `:focus`: mouse users get no ring on click, keyboard users always do. The ring is 2 to 4 px with 3:1 contrast against both the surface and the resting state. See [accessibility.md](accessibility.md).
- Image loading strategy per role: hero LCP image uses `loading="eager"`, `fetchpriority="high"`, declared `width` and `height`, plus `srcset` and `sizes`; below-the-fold imagery uses `loading="lazy"`, `decoding="async"`; logo tiles and avatars use fixed intrinsic dimensions with consistent ratio across the family. See [performance.md](performance.md).
- Visible text is the accessible name: do not hide visible text behind a mismatched `aria-label`. When a control is icon-only, the `aria-label` matches the meaning the icon conveys.
## Component Playground Discipline
A component playground (Storybook or any component workshop) exercises the contract in isolation. If a state is not in a story, it is not in the contract.
- One story per variant: every named variant in the contract has its own story; reviewers compare variants side-by-side without the host app.
- One matrix story per family: a single story renders every state-by-prop combination on one page (resting, hover, focus, disabled, loading, error; primary, secondary, ghost; small, medium, large). The matrix catches unrendered combinations.
- Visual regression on the story tree, not on integration screens: snapshot each story and the matrix story; gate PRs on the story-tree diff. Integration-level visual tests are flaky (data shifts, animation timeouts) and miss component-level drift. Story-level tests are deterministic.
- Interaction tests live next to stories: a story exercising an interaction (click, focus, submit) carries its own play function and assertions.
- Stories ship with the component, in the same package and the same review. A component without stories is not done.
## Token Transformation Pipeline
Design tokens live in one place, not in CSS, JS, iOS, Android, and the design tool independently. One transformation pipeline takes the single source of truth and emits every target. Choose ONE pipeline; mixing them produces drift between targets.
- Source format: a single JSON or YAML file (or a small directory) defining color, spacing, typography, radius, shadow, motion, and z-index tokens with semantic names. Aim for the W3C Design Tokens Community Group (DTCG) format: platform-neutral and tool-agnostic, so the source survives a future tool change.
- Pipeline options that read the source:
- A mature scriptable build that emits CSS variables, JS exports (ESM and CJS), iOS Swift, Android XML, and arbitrary custom formats via JS templates. Use when the team controls the pipeline and ships to multiple platforms.
- A design-tool plugin that round-trips tokens between design and code and integrates with the scriptable build as its build step. Use when designers own the tokens and need a design-first edit surface.
- A smaller, format-faithful DTCG-compatible CLI that emits whichever targets the project needs without the broader surface. Use when the project commits to the W3C format and wants the lowest pipeline complexity.
- Outputs by default: CSS custom properties (a `:root` block per theme), a JS or TS export module, and optionally platform-specific outputs (iOS, Android) when relevant.
- Pipeline runs in CI: it emits outputs from the source on every commit; outputs are committed (or generated and consumed inline). Drift between source and output ships unnoticed.
- No raw hex, raw px, or raw ms in component CSS: every value comes from a token, and lint enforces it.
## See Also
- [audit-workflow.md](audit-workflow.md): the multi-page audit procedure that builds the widget inventory.
- [defects.md](defects.md): symptom-to-fix lookup and the canonical geometry sweep.
- [ui-ux.md](ui-ux.md): state coverage and interaction patterns.
- [design.md](design.md): tokens, typography, and shadow language.
references/data-viz.md
---
title: Data Visualization
purpose: Chart selection, axes, color, accessibility for charts and tables, Canvas vs SVG vs WebGL rubric, timezone and DST handling, annotations, streaming chart performance, geographic projections. Every rule pairs a principle with a concrete threshold.
load-when:
task-keywords: [chart, data viz, axis, legend, colorblind, Canvas, SVG, WebGL, timezone, DST, accessibility, responsive]
symptoms: [contrast fail, slow page, slow interaction]
prereq: SKILL.md
related: [accessibility.md, responsive.md, motion.md, performance.md]
size: ~512 lines
---
# Data Visualization
Library-agnostic guidance on charts, tables, and data-dense interfaces.
## The Hierarchy of Data Display
Walk this list; the first that fits is the right choice. If a chart and a table or summary number both work, use the simpler thing.
| Need | Display |
|------|---------|
| One number with context | One number, e.g. "$48,231 monthly recurring revenue", over a one-point chart |
| Comparison or ranking (top 5, top 10) | List with bars (beats a pie chart) |
| Trend over time | Line chart |
| Distribution | Histogram or box plot |
| Composition | Stacked bar or treemap |
| Relationship between two variables | Scatter plot |
| Geographic | Map |
| Detailed records | Table |
## Choosing the Right Chart
| Data | Best chart | Avoid |
|------|-----------|-------|
| Single value | Stat card with sparkline | Chart |
| Trend over time | Line chart | Pie chart |
| Compare 2-10 categories | Bar (horizontal if labels long) | Pie chart |
| Compare 10-30 categories | Horizontal bar | Vertical bar (cramped on mobile) |
| Compare > 30 categories | Top N + "Other" or table | Bar chart with all |
| Composition (parts of whole, 2-5 categories) | Pie or donut | Pie with > 5 |
| Composition over time | Stacked area | Pie carousel |
| Distribution | Histogram or box plot | Bar chart of raw values |
| Two-variable relationship | Scatter plot | Two side-by-side bar charts |
| Multiple metrics over time | Multi-line, small multiples | Stacked line (hides individual values) |
| Geographic | Choropleth map | Pie chart |
| Hierarchical composition | Treemap or sunburst | Stacked bar |
| Funnel (sequential drop-off) | Funnel chart | Bar chart |
| Network / relationships | Graph / network diagram | Table |
### Pie / donut rules
- Maximum 5 categories; beyond that, switch to bar.
- Slices sum to 100%.
- Order by size (largest first, clockwise from 12 o'clock).
- Direct-label slices when space allows; legend if not.
- Never 3D pie.
- Don't compare two pies side by side; use a stacked bar.
### Bar chart rules
- Bar baseline at zero, always.
- Vertical bars for short labels, horizontal for long.
- Order by value (descending) unless there is a natural order (months, ranks).
- Spacing between bars: about 30-40% of bar width.
- Categorical x-axis on horizontal bars; numerical on vertical bars.
### Line chart rules
- Time on the x-axis.
- Maximum 5 lines before they tangle.
- Distinct color or dash pattern per line.
- Direct-label lines at the right end where space allows.
- Connect data points; don't put markers on every point unless you want emphasis.
## Axes
### Labels and ticks
- Always label both axes.
- Include units in the axis label or as a tick-label suffix ("$", "%", "ms").
- Don't rotate labels 90 degrees on horizontal bars; use horizontal-bar layout instead.
- Auto-skip labels when they overlap (every other, or larger interval).
- Use round tick numbers (10, 20, 50, 100), not 7, 23, 47.
- 4-7 ticks per axis is usually right.
- Y-axis baseline at zero for bar charts; truncating exaggerates differences.
- For dramatic small variations, add a "broken axis" indicator (zigzag) to be honest about it.
### Time axis granularity
| Range | Granularity |
|-------|-------------|
| < 1 day | hours |
| 1-30 days | days |
| 1-12 months | weeks or months |
| 1-5 years | months or quarters |
| > 5 years | years |
- Allow user to change granularity (day/week/month).
- Show the date format consistent with the user's locale.
### Timezone and DST handling
- Store every timestamp in UTC across server, database, transport layer, JSON payload, log line, and exported CSV. A timestamp without a timezone is a defect.
- Display in the user's local timezone using `Intl.DateTimeFormat` with `timeZone` defaulting to `Intl.DateTimeFormat().resolvedOptions().timeZone`. For meaningful dashboards (operations, finance), expose a timezone picker and persist the choice.
- Use ISO 8601 with a timezone offset: `2026-03-08T07:00:00Z` (UTC) or `2026-03-08T02:00:00-05:00` (with offset). `2026-03-08 02:00:00` (no offset) silently falls back to the parsing machine's local timezone, which is how a server timestamp displays hours off.
- DST spring-forward jumps the clock from `01:59:59` to `03:00:00` local, leaving an empty `02:00` bucket. Bucket by UTC and label by local, or bucket by local and draw the DST transition as a vertical reference line.
- DST fall-back day has 25 hours with `01:00` to `02:00` happening twice; sum-by-day double-counts that hour, so always sum from UTC.
- Across DST boundaries, choose UTC ticks for operations or local ticks for analytics to match the user's mental model.
### Number formatting
- Locale-aware via `Intl.NumberFormat`.
- Compact notation for large numbers: `1.2M`, `4.7K`.
- Currency: respect locale and currency code.
- Percentages: 1 decimal (`23.4%`) for finance, no decimals (`23%`) for categorical.
## Color in Charts
| Encoding | Palette |
|----------|---------|
| Single series | Brand primary or a single neutral |
| Sequential (ordered, low to high) | Single hue light to dark, perceptually uniform scale (viridis, magma) |
| Diverging (centered, positive vs negative) | Two-hue diverging palette (blue-white-red) |
| Categorical (no order) | Qualitative palette, distinct hues, colorblind-safe |
- Add a slight gradient (top to bottom) only on filled area charts; never on bar charts (looks gimmicky).
### Colorblind-safe palettes
- Avoid red/green pairs alone.
- Tol's "bright" palette: blue, red, green, yellow, cyan, purple, gray (distinguishable in most colorblind types).
- ColorBrewer's "Set2" or "Dark2".
- Always supplement color with shape/pattern/label so meaning isn't lost.
### Pattern alongside color
For stacked bars or pies, color carries the primary signal and a subtle hatching/dot pattern carries redundancy:
```css
.series-a { fill: var(--color-1); }
.series-b { fill: var(--color-2); background-image: repeating-linear-gradient(45deg, transparent, transparent 4px, rgba(0,0,0,0.1) 4px, rgba(0,0,0,0.1) 8px); }
```
### Contrast
- Data marks (bars, lines): at least 3:1 against the chart background.
- Text labels: 4.5:1 against the chart background.
- In dark mode, lighten the data marks; don't just keep light-mode colors.
## Legends
- Always show a legend for multi-series charts.
- Place near the chart, not detached below a scroll fold.
- Order matches data (largest to smallest, or alphabetical).
- For interactive charts, legends are clickable to toggle series visibility.
- Direct labeling (at the line/bar) often beats a separate legend.
## Tooltips
- Show on hover (desktop) or tap (mobile).
- Show the exact value (numeric labels are too small for crowded charts).
- Label which series, the x value, and the y value.
- Anchor near the cursor; don't make the user scan.
- Persist long enough to read; don't dismiss on tiny mouse movement.
- Content must be reachable via keyboard (focus on data point reveals tooltip).
Content format: x-axis label header, then per-series lines:
```
[X-axis label, e.g., March 12]
Series A: 12,400 (+18%)
Series B: 8,200 (-3%)
```
## Direct Labeling
For small datasets, label values directly instead of forcing the eye to the axis:
- Bar chart with 3-5 bars: value on or above each bar.
- Line chart: value at the rightmost point of each line.
- Pie chart: values inside slices (or outside with a leader line if too small).
## Annotations and Reference Lines
Layering rule: data marks always on top, annotations behind, except labels which sit above data marks to stay readable. Annotations carry a 3:1 contrast budget against the chart background and a color distinct from any data series color (a target line in the same color as a data series is a defect). Build annotations from a structured list (source of truth) so the chart and the data pipeline can never disagree.
| Type | How |
|------|-----|
| Threshold bands | Shaded horizontal region (SLA 99 to 100 percent, page load good zone 0 to 2.5s) as a low-opacity `<rect>` behind the data marks; label the band, not just the boundary |
| Target lines | Single dashed horizontal reference line, distinct color, label at the right end ("Target: 250ms"); line behind data marks, label above or to the side |
| Deploy / event markers | Vertical lines at meaningful x-values, color/dash differing from data lines; label rotated 90 degrees along the line or a small icon at top with hover tooltip |
| Anomaly callouts | Circle plus arrow plus short text at a specific point ("Spike: payment system outage"); visible in the static chart, hover tooltip for the full story |
| Range brackets | Horizontal bracket spanning an x-axis range, label at the top of the bracket |
## Maps and Geographic Projections
Every flat map distorts something. The defect: a Mercator world map for a statistical comparison, accidentally making Greenland look bigger than Africa. Name the projection in code (`d3.geoEqualEarth()`, not "the default projection"); the default in most libraries is Mercator, which is rarely what a statistical chart wants.
| Projection | Preserves / distorts | Use for |
|------------|----------------------|---------|
| Mercator | Preserves shape and direction; inflates area at high latitudes (Greenland appears the size of Africa) | ONLY street maps and navigation; never statistical comparisons across latitudes |
| Equal Earth | Preserves area; mild shape distortion; designed 2018 for statistical maps | Modern default for world-scale choropleths and heatmaps |
| Albers Equal Area | Preserves area for a specific region (Albers USA, Albers Europe) | Standard for US state-level and country-level statistical maps |
| Robinson, Winkel Tripel | Compromise; distort everything slightly (Robinson and Winkel Tripel are National Geographic Society defaults) | When neither shape nor area is the primary question |
| Orthographic | Earth as a sphere from a fixed viewpoint | Animated globe views or hero illustrations, not analysis |
## Animation
| Phase | Timing |
|-------|--------|
| Entrance: bars rise from baseline | 400-600ms, staggered 30-50ms per bar |
| Entrance: lines draw left to right | 600-800ms total |
| Entrance: pies sweep from 12 o'clock clockwise | 800-1000ms |
| Update: old to new values, ease-out | 300-500ms |
- On update, animate the path's `d` attribute or use a library helper for smooth transitions.
- Disable on `prefers-reduced-motion: reduce` (skip entrance, show data immediately), on live-updating views (just update), and on print.
```js
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
chart.options.animation = reduced ? false : { duration: 600, easing: 'easeOutQuart' };
```
## Accessibility
See accessibility.md for full chart and table a11y treatment.
### Screen readers
- Concise summary near the chart describing the key insight in one sentence (the trend or comparison).
- A data table inline or in a `<details>` toggle, with the same data the chart shows.
- For complex charts, use `aria-describedby` to link the chart to a longer description.
```html
<figure>
<figcaption>Chart caption describing what is plotted</figcaption>
<div class="chart" role="img" aria-labelledby="chart-summary">
<svg>...</svg>
</div>
<p id="chart-summary">
One sentence stating the key insight, in plain language, with the headline numbers.
</p>
<details>
<summary>View data table</summary>
<table>
...
</table>
</details>
</figure>
```
### Keyboard
- Interactive elements (data points, legend items) must be focusable.
- Arrow keys navigate between data points (left/right for adjacent, up/down for series).
- Enter/Space activates (toggles legend, drills down).
- Esc closes any popover or expanded view.
### Touch targets
Interactive chart elements need at least 44x44 touch area. For dense charts, expand the hit area beyond the visual element. Visible dot 4px, touch target 22px radius (44px diameter):
```html
<circle cx="100" cy="50" r="4" />
<circle cx="100" cy="50" r="22" fill="transparent" pointer-events="all" />
```
## Responsive Charts
See responsive.md for chart reflow patterns.
- Vertical bars become horizontal bars (long labels easier).
- Multi-line charts collapse to top-N or to small multiples.
- Pie charts stack labels below.
- Axis ticks reduce to 3-5 from 7-10.
- For SVG charts, listen to resize and re-render with new dimensions, throttling to 100-200ms.
```css
.chart-container { container-type: inline-size; }
@container (max-width: 480px) {
.chart .legend { display: none; }
.chart .axis-label { font-size: 0.75rem; }
}
```
## Loading and Error States
- Loading: show a skeleton or shimmer matching the chart's eventual shape; don't show a blank axis frame (looks broken).
- Empty data: show a message ("No data for this period yet."), optionally a greyed-out placeholder chart with the message overlaid, plus an action ("Try a different range" or "Connect data source").
- Error: show an error message with retry ("Couldn't load chart. Retry?"); don't show a broken/clipped chart.
## Large Datasets
For 1000+ data points:
- Aggregate: group by hour/day/week to reduce point count.
- Sample: show every Nth point.
- Summarize: show the trend, provide drill-down for detail.
- Virtualize: for long tables, only render visible rows.
- Server-side processing: aggregate on the server, ship summarized data.
- Web Workers: move client-side heavy aggregation off the main thread.
Anti-pattern: shipping 50,000 points to a `<canvas>` chart and expecting smooth interaction.
### Canvas vs SVG vs WebGL: rendering rubric
Pick the renderer by data-point count first, then by interaction model.
| Renderer | Data point ceiling | Interaction model | Accessibility | Use when |
|----------|-------------------|------------------|---------------|----------|
| SVG | About 500 nodes | DOM-native: each mark is an element, click and hover are free | Each `<rect>` or `<circle>` is a DOM node, focusable, screen-reader addressable | Bar charts, small line charts, rich per-mark interaction (tooltip, click-to-drill, focus-to-announce), DevTools-inspectable |
| Canvas | About 100,000 points | Pixel-based: hit-testing needs a hit-region map or quadtree; no DOM cost | Inherently inaccessible; pair with a `<table>` fallback or `role="img"` plus `aria-label` summary | Time-series with thousands of points, dense scatter clouds, heatmaps |
| WebGL | Above 100,000 points or when shaders are needed | Pixel-based, GPU-accelerated; same hit-testing problem as Canvas plus GPU memory budget | Same problem and fallback as Canvas | Million-point scatter, real-time streaming dashboards with many series, per-mark shading (instanced rendering, density estimation) |
Rule of thumb:
- Under 500 marks: SVG. DOM tax negligible, interaction free, accessibility best.
- 500 to 100,000 marks: Canvas (SVG drops frames at hover). Add a quadtree for hover hit-testing.
- Above 100,000 marks or shader effects: WebGL via a library that handles the boilerplate (deck.gl, regl-based wrappers); expect to write your own picking pipeline.
Hybrid: render the data layer in Canvas or WebGL for speed, render axes, legends, and overlay annotations in SVG on top so they stay accessible; both layers share a coordinate system.
## Tables
### Anatomy
```html
<table>
<caption>Table caption describing the data</caption>
<thead>
<tr>
<th scope="col">Row category</th>
<th scope="col">Metric A</th>
<th scope="col">Metric B</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Row label</th>
<td>4,200,000</td>
<td>+12%</td>
</tr>
...
</tbody>
</table>
```
- `<caption>` describes the table.
- `<th scope="col">` for column headers, `<th scope="row">` for row headers.
- For complex tables (multi-level headers), use `headers="..."` to associate cells with their headers.
### Visual rules
- Tabular figures (`font-variant-numeric: tabular-nums`) for numeric columns.
- Right-align numbers, left-align text, center-align symbols/icons.
- Subtle low-contrast row dividers rather than alternating row colors (which look dated and reduce readability).
- Sticky header for long tables (`position: sticky` on `<thead>`).
- Hover state on rows for navigability.
### Sorting
- Click column header to sort ascending; click again for descending.
- Show current sort direction with an arrow icon.
- Use `aria-sort="ascending|descending|none"` on the column header.
- Multi-column sort: hold Shift to add additional sorts.
### Filtering
- Per-column filters: dropdown, search, or range slider depending on data type.
- Active filters visible as chips above the table.
- "Clear all" link.
### Selection
- Checkbox per row.
- Select-all checkbox in the header with an indeterminate state for partial selection.
- Show count of selected rows.
- Bulk actions appear when rows selected.
### Pagination vs infinite scroll
- Pagination for tables: predictable, accessible, supports deep linking.
- Infinite scroll for activity feeds: low-stakes, sequential reading.
- Don't mix.
### Mobile tables
For wide tables on small screens:
- Horizontal scroll with sticky first column (the row identifier).
- Card view: each row becomes a card, fields as label/value pairs.
- Collapse: show a few key columns, expand row to see all.
```css
.table-scroll {
overflow-x: auto;
scrollbar-width: thin;
}
.table-scroll table {
min-inline-size: 720px;
}
```
## Stat Cards / KPI Tiles
The most important number gets a stat card. Anatomy and order:
```
[ Stat Card ]
[ Label: short description of the metric ]
[ Value: the formatted number, largest type ]
[ Change: direction icon + delta vs comparison period ]
[ Sparkline: subtle line chart of recent history ]
```
- Label first (small, muted).
- Value largest (display type).
- Change indicator with direction icon and color (green up, red down), paired with text so color isn't the only signal.
- Optional sparkline: subtle (single neutral color), no axis.
- Optional click affordance to drill down.
## Real-Time Updates
For live-updating data:
- Update without animation (or very subtle pulse on changed values).
- Don't shift other content; new rows arrive at the top with a brief highlight.
- Allow pause/resume so users can read without flicker.
- Show a last-updated timestamp.
### Streamed chart performance
Four composing techniques keep a chart smooth at high stream rates: rAF-coalesce caps the render rate, ring buffers cap the data size, OffscreenCanvas moves the work off-main-thread, visibility-driven throttling drops to background-rate.
- rAF-coalesce updates: buffer incoming events, redraw once per `requestAnimationFrame`, since the screen refreshes at 60 Hz (or 120 Hz on high-refresh displays) and rendering faster wastes CPU.
```js
let pending = [];
let scheduled = false;
socket.addEventListener('message', (event) => {
pending.push(JSON.parse(event.data));
if (!scheduled) {
scheduled = true;
requestAnimationFrame(flush);
}
});
function flush() {
scheduled = false;
for (const point of pending) ringBuffer.push(point);
pending = [];
renderChart(ringBuffer);
}
```
- Ring buffers for fixed-size sliding windows: a fixed-size array with a head pointer so the data structure does not grow unbounded; old points fall off the back as new push on the front.
- OffscreenCanvas plus transfer to main: move rendering to a Web Worker holding an `OffscreenCanvas`, composite the result on the main thread so it stays responsive for input.
- Cap update rate to display refresh: render at 60 Hz even at 1000 messages/second; throttle further (30 Hz, 15 Hz) when off-screen, in a background tab (`document.visibilityState === 'hidden'`), or when the user interacts elsewhere.
## Common Data Viz Mistakes
- Pie chart with > 5 categories.
- Bar chart with truncated y-axis (exaggerates differences).
- Line chart with too many series tangling.
- 3D charts.
- Tooltip showing only a value with no series/x-axis context.
- Color-only encoding (red good / red bad without text or icon).
- Chart that is a bar chart on desktop and a different chart entirely on mobile (jarring).
- Chart with no axis labels.
- Chart with no units.
- Chart that loads with a flicker (no skeleton, then sudden content).
- Animation that runs on every data refresh (every 5s in a live data view).
- Chart in dark mode with light-mode colors.
- Inaccessible chart with no text alternative.
- Tables without sorting or filtering on data-heavy views.
- Tables with row colors so loud they distract from data.
- Numbers without locale formatting (US format in a German UI).
- Tooltip that doesn't work on touch.
## Self-Healing for Data Viz
Before declaring work complete:
- [ ] Right chart for the data
- [ ] Bar charts have zero baseline
- [ ] Pies have <= 5 categories
- [ ] Axes labeled with units
- [ ] Colorblind-safe palette
- [ ] Color is never the only signal (paired with shape/pattern/label)
- [ ] Legend shown and accessible
- [ ] Tooltip works on hover AND tap
- [ ] Chart has text alternative (summary or table)
- [ ] Interactive elements keyboard-accessible
- [ ] Touch targets >= 44x44
- [ ] Responsive: simplifies/reflows on small screens
- [ ] Loading skeleton matches eventual layout
- [ ] Empty state has a message and action
- [ ] Error state has retry
- [ ] Numbers locale-formatted
- [ ] Animation respects `prefers-reduced-motion`
- [ ] Tested in light AND dark mode
- [ ] Tables: sorting, sticky header, mobile-adaptive
## See Also
- [accessibility.md](accessibility.md) for chart and table accessibility
- [responsive.md](responsive.md) for chart reflow patterns
- [motion.md](motion.md) for chart animation
- [performance.md](performance.md) for streaming and large-dataset budgets
references/defects.md
---
title: Common Visual Defects and Geometry Checks
purpose: Symptom-to-fix lookup for visible defects, plus the canonical programmatic geometry sweep and content-and-markup sweep that gate every audited route.
load-when:
task-keywords: [defect, bug, regression, geometry, threshold, audit, sweep, screenshot]
symptoms: [viewport overflow, horizontal scroll, duplicate id, font swap CLS, iOS 100vh, rubber-band scroll, focus not visible, inert leak]
prereq: SKILL.md
related: [audit-workflow.md, components.md, accessibility.md, responsive.md]
size: ~254 lines
---
# Common Visual Defects and Geometry Checks
Symptom-to-fix lookup for visible defects, plus the canonical geometry sweep and content-and-markup sweep that gate every audited route at both capture viewports.
## How to Use
1. Identify the symptom on the rendered page or screenshot.
2. Look it up in the defect table.
3. Apply the fix at the right layer (component, page, or design token).
4. Re-run the geometry sweep on the affected route at both audit viewports.
5. Re-capture before-and-after screenshots; confirm no neighbor regressed.
One big table by design: one search lands the symptom regardless of topic. Topical references point here rather than restate it.
## Defect Lookup Table
| Defect | Likely cause | Fix |
|--------|--------------|-----|
| Button shifts oddly on hover | Parent and child both transform | Disable the child transform inside the animated card, or move the transform to one layer |
| Dark line after sticky header | Border/shadow/background mismatch between header and section top | Normalize header border, shadow, and section top background so the seam is invisible |
| Dropdown not centered | Absolute position tied to item edge | `left: 50%`, `transform: translateX(-50%)`, clamp width on small screens |
| Mobile dropdown off-canvas | Desktop hover rule overrides mobile rule | Add a media-specific override; constrain the dropdown to the viewport |
| Card row uneven heights | Content-dependent heights, no stretch | Grid stretch, flex column, `height: 100%`, stable `min-height` |
| Only nested CTA clickable | Card behaves as a link but the anchor wraps only the CTA | Make the full card an anchor, or use a button-card pattern with a stretched pseudo-element on the title link; never nest anchors (invalid HTML per the `<a>` content model) |
| Duplicate arrows | CSS pseudo-element arrow plus a text or icon arrow | Choose one arrow source; suppress the other by component class |
| Pricing implies wrong plan | Ambiguous trial or setup wording on shared cards | State paid plan terms and fees plainly; remove repeated misleading phrases |
| Social icons too far apart or too small | Independent margins, small hit boxes | Fixed 44px boxes plus `gap` |
| Cross or check icon off-center | Pseudo-element dimensions mismatch the icon box | Square icon box with absolutely centered pseudo-elements, or inline SVG |
| Mobile horizontal scroll | Hidden off-canvas element, wide image, long unbreakable word, or `100vw` misuse | Inspect `scrollWidth`; constrain media; `min-width: 0` on grid children; avoid `width: 100vw` inside padded containers |
| Low-quality page versus reference | Thin sections, repeated template copy, generic stock images | Add useful sections, vary copy, use relevant images, tighten hierarchy |
| Same widget looks different across pages | Duplicated markup, page-local CSS, no component contract | Extract a shared component or normalize markup and classes to one contract; see [components.md](components.md) |
| Many card variants without purpose | Ad hoc class combinations applied page by page | Define named variants in the component; map old usages into them |
| Integration or logo tiles inconsistent in size | Each page hand-codes image and card rules | One logo tile component: fixed image box, label treatment, grid behavior |
| CTA bands vary randomly | Page-specific final sections | One final-CTA component with explicit variants: centered, split, compact, gradient |
| Focus ring clipped | Parent `overflow: hidden` with insufficient padding | Move the ring to an `outline` (paints outside the box), or add internal padding so the ring fits |
| Focus ring invisible on dark surfaces | Single-color ring chosen for light mode only | Ring color with 3:1 contrast against both surface and resting state, in both light and dark mode |
| Hero image causes CLS | Missing intrinsic `width` and `height` | Declare `width` and `height` attributes; reserve space with `aspect-ratio` |
| Late-loading hero image hurts LCP | Hero image is `loading="lazy"` or behind hydration | `loading="eager"` plus `fetchpriority="high"` on the hero LCP image only |
| Webfont flash on first paint | `font-display: block` or none set | `font-display: swap` for body, `optional` for display; preload at most one critical weight |
| Unstyled flash on hydration | Critical CSS not inlined | Inline above-the-fold CSS under 14 KB so it fits the first round trip |
| Button label truncates on mobile | Fixed width or `white-space: nowrap` with insufficient room | Constrain by `max-width`, allow wrap, or shorten the label |
| Form validation appears late | Validation runs on every keystroke, then debounces | Validate on blur for new errors; clear errors live as the user types |
| Modal scrolls with the page | Body scroll not locked while modal open | Lock body scroll on open, restore on close. Compensate scrollbar width or the page shifts sideways: measure `gap = window.innerWidth - document.documentElement.clientWidth`, set `body { overflow: hidden }` plus `padding-right: gap` while open, restore both on close. Overlay-scrollbar systems give `gap` 0 (no shift). Restore scroll position too. |
| Modal traps keyboard but not screen reader | Background interactive in the accessibility tree | Mark background `inert` (or `aria-hidden="true"` plus `pointer-events: none`) while open |
| Skip link overflows the viewport while hidden | `position: absolute; left: -9999px` extends the layout box, or focus inherits the off-screen position | Hidden: `clip-path: inset(100%)` plus `position: absolute; width: 1px; height: 1px; overflow: hidden`. On `:focus`: `clip-path: inset(0); position: fixed; top: 1rem; left: 1rem` |
| Tables overflow on mobile | Fixed-width table, no scroll wrapper | Wrap in a scroll container with `overflow-x: auto`, or transform to cards below the breakpoint |
| Empty state is blank | No specific message, no primary action | Add a specific message, the condition that fills it, and a primary action that resolves it |
| iOS Safari `100vh` clips behind home indicator; rubber-band scroll exposes page background | `100vh` resolves to the largest viewport; inner-container momentum scroll bleeds to the document | `100dvh` for currently-visible height, `100svh` for the smallest stable height (no shift on URL-bar collapse), `100lvh` for the largest. On momentum-scroll containers add `-webkit-overflow-scrolling: touch` and `overscroll-behavior: contain` |
| Modal `z-index: 9999` sits behind an ancestor | Ancestor establishes a stacking context (`transform`, `filter`, `position: fixed`, `will-change`, `opacity < 1`, or `isolation: isolate`); the child `z-index` is bounded by that context | Portal the modal to `<body>` so it escapes the context, or hoist the stacking-context owner. Note: `position: fixed` does NOT escape a `transform` ancestor's context |
| `-webkit-autofill` paints inputs yellow, ignores dark-mode surface color | Browser applies a non-styleable background; CSS `background-color` does not win | Box-shadow trick: `input:-webkit-autofill { -webkit-box-shadow: 0 0 0 30px var(--surface) inset; -webkit-text-fill-color: var(--text); transition: background-color 9999s ease-out 0s; }`. Set light and dark variants; verify both |
| Font-swap CLS persists even with `size-adjust` | `size-adjust` scales glyph size only; line metrics (ascent, descent, line-gap) still differ and the swap shifts layout | Full metrics-override set on the fallback `@font-face`: `ascent-override`, `descent-override`, `line-gap-override`, and `size-adjust` tuned to the web font. Tools like `fontaine` or `next/font` compute the values; or sample the web font's metrics manually |
| Modal opens but background still announced | `inert` applied to the wrong ancestor, or `aria-hidden="true"` on the body that contains the modal (hiding the modal too) | Apply `inert` to every sibling of the dialog at its level (not the body, not a parent). A top-layer `<dialog>` does this automatically; a portalled modal in body marks all OTHER body children `inert` while open, restores on close. Never put `aria-hidden` on an ancestor that contains the modal |
## Programmatic Geometry Sweep
Catches what a screenshot pass misses: sub-44 hit targets buried in long pages, hidden text overflow on edge cases, viewport bleed from a single rogue element, duplicate arrows that read fine alone but stack across siblings.
Run from a headless browser of your choice (Puppeteer, Playwright, or equivalent). Standard DOM and CSSOM only:
```js
const issues = await page.evaluate(() => {
const out = [];
const visible = (el) => {
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return r.width > 0 && r.height > 0 && cs.display !== "none"
&& cs.visibility !== "hidden" && parseFloat(cs.opacity) > 0;
};
const nameFor = (el) =>
el.tagName.toLowerCase()
+ (el.className
? "." + String(el.className).trim().split(/\s+/).slice(0, 3).join(".")
: "");
if (document.scrollingElement.scrollWidth > window.innerWidth + 1) {
out.push({ type: "viewport-bleed", value: document.scrollingElement.scrollWidth, viewport: window.innerWidth });
}
for (const el of document.querySelectorAll("body *")) {
if (!visible(el)) continue;
const cs = getComputedStyle(el);
if (el.clientWidth > 0 && el.scrollWidth > el.clientWidth + 2 && cs.overflowX === "visible") {
const text = (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80);
if (text && !["HTML", "BODY", "MAIN", "SECTION"].includes(el.tagName)) {
out.push({ type: "text-overflow", selector: nameFor(el), text });
}
}
}
for (const el of document.querySelectorAll("a, button, input, select, textarea, [role='button'], .btn")) {
if (!visible(el)) continue;
const r = el.getBoundingClientRect();
if (r.width < 44 || r.height < 44) {
out.push({ type: "small-target", selector: nameFor(el), width: Math.round(r.width), height: Math.round(r.height) });
}
}
for (const el of document.querySelectorAll("a, .btn-text")) {
const text = (el.textContent || "").replace(/\s+/g, " ").trim();
if (/→\s*→|›\s*›|»\s*»/.test(text)) out.push({ type: "duplicate-arrow", text });
}
return out;
});
```
The sweep returns nine check categories. Run it on every audited route at both capture viewports.
| # | Check | Triggers when |
|---|-------|---------------|
| 1 | Viewport bleed | `document.scrollingElement.scrollWidth` exceeds `window.innerWidth` by more than 1 px |
| 2 | Visible text overflow | An element's `scrollWidth` exceeds its `clientWidth` by more than 2 px while `overflow-x` is `visible` |
| 3 | Small interactive target | A visible interactive element measures less than 44 by 44 CSS pixels |
| 4 | Duplicate arrow | A link or text-button label contains stacked arrow glyphs (`→ →`, `› ›`, `» »`) |
| 5 | Pricing or card clickable mismatch | A card behaves as a link but only an inner CTA is the anchor (extend with a per-project selector list) |
| 6 | Header or dropdown centering | The dropdown's horizontal center is more than 4 px from its trigger center (extend with the per-project selector) |
| 7 | Mobile drawer scroll lock | Body scroll is not locked while the drawer is open (extend with a per-project trigger sequence) |
| 8 | Focus state presence | A keyboard-focusable element produces no visible outline change on focus (extend with a per-project focus drive) |
| 9 | Project-specific extension | Slot for negative letter-spacing, banned colors, or any rule the project standardizes on |
The base snippet covers checks 1 through 4 directly. Checks 5 through 8 need project-specific selectors and triggers added to the same `evaluate` call. Check 9 is the project-rule slot.
Filter false positives only when you can explain them: hidden off-canvas content, intentionally overflowing dropdown internals that do not affect the page viewport, or a tap target intentionally part of a larger ancestor hit area. Document the filter so the next run does not re-discover it.
The small-target check must EXEMPT inline text links (per the WCAG 2.5.8 inline exception) or it floods with false positives. The base snippet does NOT do this yet; extend its small-target loop to skip anchors whose computed `display` is `inline` or `inline-block` and that sit in running text, a breadcrumb, or a footer text-link list. Constrain the exemption to anchors on purpose: buttons and menu triggers default to `inline-block` and often live inside an `<li>`, so a broader rule would wrongly exempt genuinely too-small standalone controls. After the extension, every standalone control (button, toggle, CTA, icon button, menu trigger, form control) is still flagged. Minimal conditional at the top of the small-target `for...of` loop (use `continue`, not `return`, or you exit the whole `page.evaluate` callback):
```js
const cs = getComputedStyle(el);
if (el.tagName === "A" && (cs.display === "inline" || cs.display === "inline-block") && el.closest("p, nav[aria-label*='breadcrumb' i], footer")) continue;
```
## Programmatic Content and Markup Sweep
Catches content, markup, and SEO defects a screenshot cannot show: missing canonical, second H1, over-long title, unparseable JSON-LD, image with no alt, orphan page, duplicate id. Run over the BUILT HTML (shipped output, not the dev server) on every route. It complements, does not replace, the Rich Results Test (which validates JSON-LD content against Google's rules); this sweep only checks markup is present and parseable.
```js
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
const DIST = process.argv[2] || "dist";
const files = [];
(function walk(d){ for (const e of readdirSync(d)) {
const p = join(d, e);
statSync(p).isDirectory() ? walk(p) : p.endsWith(".html") && files.push(p);
}})(DIST);
const decode = (s) => s
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
.replace(/'|'/g, "'").replace(/"/g, '"');
const problems = [];
const linkedTo = new Set();
const pages = [];
for (const f of files) {
const rel = f.replace(DIST, "");
const s = readFileSync(f, "utf8");
const robotsTag = (s.match(/<meta\b[^>]*\bname=["']robots["'][^>]*>/i) || [])[0] || "";
const noindex = /noindex/i.test(robotsTag);
pages.push(rel.replace(/index\.html$/, "").replace(/\.html$/, "") || "/");
const h1 = (s.match(/<h1[\s>]/g) || []).length;
if (h1 !== 1) problems.push(`H1=${h1} ${rel}`);
const title = decode((s.match(/<title>([^<]*)<\/title>/) || [])[1] || "");
if (!noindex && (title.length < 50 || title.length > 60)) problems.push(`TITLE ${title.length} ${rel}`);
const descTag = (s.match(/<meta\b[^>]*\bname=["']description["'][^>]*>/i) || [])[0] || "";
const desc = decode((descTag.match(/\bcontent=["']([^"']*)/i) || [])[1] || "");
if (!noindex && (desc.length < 140 || desc.length > 160)) problems.push(`DESC ${desc.length} ${rel}`);
if (!noindex && !/rel=["']canonical["']/.test(s)) problems.push(`NO-CANONICAL ${rel}`);
if (!noindex && !/property=["']og:title["']/.test(s)) problems.push(`NO-OG ${rel}`);
for (const m of s.matchAll(/<img\b[^>]*>/g)) if (!/\salt=/.test(m[0])) problems.push(`IMG-NO-ALT ${rel}`);
if (/href=["']#["']/.test(s)) problems.push(`DEAD-ANCHOR ${rel}`);
const ids = [...s.matchAll(/\sid=["']([^"']+)["']/g)].map((m) => m[1]);
const seen = new Set(), dup = new Set();
for (const id of ids) { if (seen.has(id)) dup.add(id); seen.add(id); }
if (dup.size) problems.push(`DUP-ID ${rel}: ${[...dup].join(", ")}`);
for (const m of s.matchAll(/<script type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/g)) {
try { JSON.parse(m[1]); } catch (e) { problems.push(`BAD-JSONLD ${rel}: ${e.message}`); }
}
for (const m of s.matchAll(/href=["'](\/[^"'#?]*)/g)) linkedTo.add(m[1].replace(/\/$/, "") || "/");
}
for (const p of pages) {
const norm = p.replace(/\/$/, "") || "/";
if (norm !== "/404" && !linkedTo.has(norm)) problems.push(`ORPHAN ${norm}`);
}
console.log(problems.length ? problems.join("\n") : "NO PROBLEMS");
```
| Flag | Triggers when |
|------|---------------|
| `H1=count` | The count of `<h1` matches is not exactly 1 |
| `TITLE` | Non-noindex page `<title>` length is < 50 or > 60 characters |
| `DESC` | Non-noindex page meta description length is < 140 or > 160 characters |
| `NO-CANONICAL` | Non-noindex page missing `rel="canonical"` |
| `NO-OG` | Non-noindex page missing `property="og:title"` |
| `IMG-NO-ALT` | Any `<img>` without an `alt=` attribute |
| `DEAD-ANCHOR` | Page contains `href="#"` |
| `DUP-ID` | Any `id=` value appears more than once on a page |
| `BAD-JSONLD` | A `<script type="application/ld+json">` block fails `JSON.parse` (reports the error message) |
| `ORPHAN` | Any page (other than `/404`) not in the set of internally linked-to normalized paths |
A run is clean when it prints `NO PROBLEMS`. Tune the length thresholds to the project's title and description bars. The `decode()` helper covers the five most common entities only (`&`, `<`, `>`, `'`/`'`, `"`); a title carrying ` `, `'`, `’`, or any other entity over-counts against the 50 to 60 length check, so swap in a complete entity decoder (a small library, or read `document.title` in a DOM context) before trusting the length numbers. Node and string parsing only, no browser; pair it with the geometry sweep (which needs a headless browser) for full coverage. Filter a false positive only when explainable (a deliberately noindex utility page, an intentional single-instance id) and document it.
## Per-Check Thresholds
These make the bars measurable; they do not add bars. They align with SKILL.md North Star Targets and the touch-target rule in [ui-ux.md](ui-ux.md).
| Check | Threshold | Source |
|-------|-----------|--------|
| Capture viewports | `1440x900` desktop, `375x812` mobile | [responsive.md](responsive.md) breakpoints table |
| Touch target minimum | 44 by 44 CSS pixels | Rule 29 in [quick-reference.md](quick-reference.md), [ui-ux.md](ui-ux.md) hit targets table |
| Horizontal overflow tolerance | 0 px on mobile, 0 px on desktop | [responsive.md](responsive.md) |
| Duplicate arrow count | 0 across all visible labels | This file, defect table |
| Dropdown centering tolerance | 4 px from trigger center | This file, geometry sweep |
| Focus ring contrast | 3:1 against surface and resting state | [accessibility.md](accessibility.md) contrast targets |
| LCP image declared dimensions | Both `width` and `height` present | Rule 1 in [quick-reference.md](quick-reference.md), [performance.md](performance.md) |
## Acceptance
A polish pass is not complete until:
- The geometry sweep returns zero issues on every audited route at `1440x900` and `375x812`.
- Every defect found in the lookup table is fixed at the right layer (component, page, or design token).
- Re-captured screenshots show the fix and no neighbor regression.
Until then, the work is not done. Do not present an incomplete visual pass as complete.
## See Also
- [audit-workflow.md](audit-workflow.md) for the full multi-page audit procedure
- [components.md](components.md) for component contracts and drift detection
- [accessibility.md](accessibility.md) for focus, contrast, and skip-link patterns
- [responsive.md](responsive.md) for capture viewports and breakpoints
- [performance.md](performance.md) for LCP, font, and image strategy
references/design.md
---
title: Design Aesthetics
purpose: Framework-agnostic aesthetic direction covering typography, color systems (OKLCH, P3 wide gamut), palette construction, elevation, atmosphere, brand voice, and dark mode. States concrete thresholds and CSS properties so an interface reads intentional, not generic.
load-when:
task-keywords: [design, typography, color, palette, OKLCH, P3, spacing, composition, atmosphere, dark mode, brand, variable font]
symptoms: [dark mode broken, contrast fail, score dropped]
prereq: SKILL.md
related: [ui-ux.md, responsive.md, motion.md, accessibility.md]
size: ~543 lines
---
# Design Aesthetics
Build interfaces that look intentional and distinctive, the opposite of generic AI styling. Every rule below is a principle plus a concrete check.
## The Generic AI Look (avoid all of these)
If a page reads like this list, restart with an actual aesthetic direction.
- Inter (or Geist, or Space Grotesk) on pure white with text-slate-600 body text.
- Purple-to-pink linear gradients on hero CTAs.
- Glassmorphism without context: backdrop-blur cards floating on a colorful background.
- Centered-everything layouts: hero text centered, three feature cards centered, centered CTA.
- Three-card pricing grid with the middle card highlighted via gradient border.
- Lucide icons in 1.5px outline at 20px above every section heading.
- A "trusted by" logo strip with five evenly-spaced grayscale logos.
- A vague abstract gradient background with low opacity blobs.
- Lorem-style placeholder copy that says nothing specific.
- text-balance on every heading with no concern for what is balanced.
- A FAQ section using <details> as the last block before the footer.
- A footer with five columns of links that nobody clicks.
## Pick a Direction (and commit)
Pick ONE aesthetic lane and execute it precisely. Intentionality, not intensity, is the key.
| Lane | Personality |
|------|------------|
| Refined minimal | Quiet, generous whitespace, muted palette, distinctive type |
| Editorial | Magazine-like, serif display, asymmetric grids, considered photography |
| Brutalist | Raw, unstyled-feeling, monospace, hard borders, no shadows |
| Maximalist / chaotic | Layered textures, decorative elements, generous color, dense |
| Retro-futuristic | 80s/90s aesthetic, neon, CRT scan lines, monospace, deep blacks |
| Organic / natural | Soft curves, earthy palette, hand-drawn elements, pastels |
| Industrial / utilitarian | Engineering-feeling, technical drawings, fixed-width type, schematic |
| Luxury / refined | Restrained, elegant serifs, high contrast, generous space |
| Playful / toy-like | Bright, rounded, illustrated, animated micro-interactions |
| Geometric / Bauhaus | Primary colors, geometric shapes, grid-driven, sans-serif |
| Magazine / softlit | Pastel surfaces, large photography, warm shadows, lifestyle |
| Cyberpunk / dark futurist | Deep saturated darks, hard accent colors, glitch elements |
Per chosen lane, commit to all five:
- One typeface family for headings, one for body (sometimes the same).
- A constrained palette: one dominant, 1-2 accents, neutral scale.
- A specific elevation/shadow language.
- A specific border-radius scale.
- A specific motion language.
## Typography
Type carries the most personality. Pair a distinctive display face with a refined body face, or use one excellent variable font for both. Anti-pattern: pairing two similar sans-serifs; they fight without contrasting.
### Face requirements
| Face | Requirements |
|------|-------------|
| Body | Optimized for screens (hinted, generous x-height); multiple weights (regular, medium, semibold, bold minimum); decent at small sizes (14-16px); wide language coverage if multi-locale; variable font preferred (one file replaces 3-5 weights) |
| Display | Distinctive at large sizes; less constrained on rendering quality (used 24-96px); has personality (if the hero heading could be any other product's logo, it is too generic) |
### Pairings by lane (worked examples)
| Lane | Display | Body |
|------|---------|------|
| Refined minimal | Söhne, Suisse Int'l, GT America | Söhne, Inter Display |
| Editorial | GT Sectra, Tiempos Headline, Fraunces (variable) | Source Serif, Tiempos Text, Newsreader |
| Brutalist | Söhne Mono, JetBrains Mono, Berkeley Mono | Söhne Mono, IBM Plex Mono |
| Maximalist | Migra, Pangea, Editorial New | Inter, Söhne |
| Retro-futuristic | VT323, Press Start 2P, IBM Plex Mono | IBM Plex Mono, Space Mono |
| Organic | Fraunces (variable), Saol, Reckless | Söhne, Untitled Sans |
| Industrial | Söhne Mono, Berkeley Mono | Söhne Mono |
| Luxury | Tiempos Headline, GT Sectra Display, Saol | Tiempos Text, GT America |
| Playful | Migra, Editorial New, ABC Diatype | Söhne, Untitled Sans |
| Bauhaus | Neue Haas Grotesk, Söhne, Inter | Same |
| Magazine | Tiempos Headline, GT Sectra, Newsreader | Newsreader, Source Serif |
| Cyberpunk | JetBrains Mono, Departure Mono, Berkeley Mono | Same |
Open-source equivalents:
- Sans (refined): Inter Display, Manrope, Public Sans, Geist.
- Sans (characterful): Outfit, Bricolage Grotesque, Bagoss Standard.
- Serif (refined): Newsreader, Source Serif 4, Lora, Crimson Pro.
- Serif (display): Fraunces (variable), DM Serif Display, Playfair Display.
- Mono: JetBrains Mono, IBM Plex Mono, Geist Mono, Departure Mono, VT323.
- Display / unusual: Bagoss Condensed, Migra, Editorial New (paid), Bricolage Grotesque (variable, free).
Budget: two families and four weights. Variable fonts stretch this without the byte cost.
### Variable-font axes beyond wght
Vary one or two axes intentionally per project. Animating three at once reads as a typography demo, not a product.
| Axis | Use |
|------|-----|
| wght (weight) | Familiar 100 to 900; emphasis, hierarchy, grade adjustment |
| wdth (width) | e.g., 75 narrow to 125 wide; constrained-width nav labels, poster headlines, subtle hierarchy |
| opsz (optical size) | Pair with `font-optical-sizing: auto;` so the browser picks the right shape for the current font-size |
| slnt (slant) | True italic-without-an-italic-cut when the font has no italic master |
| GRAD (grade) | Thickens strokes without changing horizontal metrics; dark-mode trick nudges grade up 50 to 100 units to compensate for light-on-dark optical thinning without reflow |
| Custom (MONO, CASL, CRSV, XOPQ, others) | Appear in many modern variable fonts (Recursive, Roboto Flex, Fraunces); read the spec sheet and use exposed knobs |
### Type scale (build it, do not pick by feel)
| Modular ratio | Use |
|--------------|-----|
| 1.125 (major second) | Dense UI, data-heavy surfaces |
| 1.2 (minor third) | Default for routine application UI |
| 1.25 (major third) | Generous, spacious layouts |
| 1.333 (perfect fourth) | Editorial, dramatic |
| 1.5 (perfect fifth) | Luxury, very dramatic |
A common 1.25 scale at base 16px: `12.8 -> 16 -> 20 -> 25 -> 31.25 -> 39 -> 48.8 -> 61 -> 76.3`. Round to whole pixels for crispness.
### Display vs body type rules
| Property | Display | Body |
|----------|---------|------|
| Line height | 1.0-1.2 (tighter) | 1.5-1.75 for paragraphs |
| Letter-spacing | Negative (-0.01em to -0.03em) | Default |
| Weight | Heavier at large sizes; medium or bold at very large | 400 body, 500 UI labels, 600 emphasis |
| Size | Used 24-96px | 16px minimum |
| Line length | n/a | 60-75 characters |
| Layout | May break the grid, overlap, or extend into margins | Standard flow |
### Numerics and wrapping
Tabular figures: for prices, percentages, timers, data tables, use `font-variant-numeric: tabular-nums` to prevent column shift between renders.
```css
.price, .stat {
font-variant-numeric: tabular-nums;
}
```
- `text-wrap: balance`: only on H1/H2 (not every heading); browser balances lines for short text.
- `text-wrap: pretty`: on long-form copy to avoid orphans / one-word bottom lines. It is multi-pass (lay out, evaluate, re-lay out), so reserve it for callout/lead paragraphs and captions and leave routine body copy on default wrapping.
## Color
### Palette construction (start from purpose)
1. Background neutral: light usually `#FAFAFA`, `#FFFFFF`, or a faint warm/cool tint; dark `#0A0A0B`, `#111`, or a colored dark like navy `#0B1020`. Plan light and dark separately.
2. Foreground neutral: maximum contrast against background, light `#0A0A0B`, dark `#FAFAFA`; add muted variants by reducing opacity or mixing toward the background.
3. Surface scale: 3-5 elevation levels (background, surface, surface-elevated, surface-overlay, surface-modal), each lighter in dark mode or with more shadow in light mode.
4. Brand primary: one color, saturated enough to read on both backgrounds; check both light and dark mode contrast.
5. One or two accents: used sparingly for highlights, links, decorative elements; harmonize with the primary.
6. Semantic colors: success (green), warning (amber/yellow), danger (red), info (blue), each at 4.5:1 against surfaces.
7. Neutral scale: 9-12 stops (50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950); pick a tinted neutral (warm stone, cool slate, colored zinc/gray) and avoid pure neutral gray for character.
### OKLCH and OKLab for perceptual palettes
Hex and HSL produce perceptually uneven ramps. OKLCH/OKLab parameterise color in a perceptually uniform space so equal steps along lightness, chroma, or hue look equal.
```css
:root {
--brand-500: oklch(62% 0.18 270);
--brand-600: oklch(54% 0.18 270);
--brand-400: oklch(70% 0.18 270);
}
```
`color-mix()` in OKLCH gives guaranteed-uniform steps:
```css
:root {
--brand: oklch(62% 0.18 270);
--brand-tint: color-mix(in oklch, var(--brand) 20%, white);
--brand-shade: color-mix(in oklch, var(--brand) 80%, black);
}
```
Use OKLCH for:
- Lightness ramps in neutral/brand scales (50 through 950): step lightness by a constant delta, hold chroma and hue.
- Categorical chart hue ramps: hold lightness and chroma, step hue by a constant delta; twelve perceptually distinct colours fit comfortably (see data-viz.md).
- State variants (hover, active, focus): mix the base color toward foreground or background by 5 to 15 percent in OKLCH.
### P3 wide-gamut color
Modern displays render the P3 gamut, roughly 35 percent larger than sRGB. Saturated P3 brand colors fall back to a duller sRGB version on older monitors; serve both. Treat P3 as progressive enhancement: define every brand color with an sRGB fallback first, then override inside `@media (color-gamut: p3)`. A P3 token without a fallback renders as the unstyled default on older hardware. Test both modes.
```css
.brand-cta {
background: #4b5cf0; /* sRGB fallback */
background: color(display-p3 0.25 0.36 0.95); /* P3 on capable displays */
}
@media (color-gamut: p3) {
:root {
--brand: oklch(62% 0.22 270 / display-p3);
}
}
```
### Color philosophy
- Dominant + sharp accents beats evenly-distributed: pick a dominant (the brand) and let it occupy 60-80% of accent area; accents at 10-30% for key calls; neutrals for the rest.
- The 60-30-10 rule: roughly 60% neutral, 30% secondary, 10% accent, adjusted per page type.
- Dark mode is its own design: do not invert; pick darker, desaturated brand variants (e.g., `#5B5DEF` in light becomes `#7376F5` in dark for proper contrast).
- Saturation for hierarchy: higher saturation reads as more important; desaturate less important elements.
### Color ratios (verify against accessibility.md)
Light mode:
| Element | Foreground | Background | Ratio |
|---------|-----------|------------|-------|
| Body text | `#0a0a0b` | `#ffffff` | 20:1 (AAA) |
| Body muted | `#525860` | `#ffffff` | 7:1 (AAA large) |
| Body subtle | `#71757b` | `#ffffff` | 4.6:1 (AA) |
| Borders | `#e4e4e7` | `#ffffff` | 1.4:1 (decorative; not text) |
Dark mode:
| Element | Foreground | Background | Ratio |
|---------|-----------|------------|-------|
| Body text | `#fafafa` | `#0a0a0b` | 19:1 (AAA) |
| Body muted | `#a1a1aa` | `#0a0a0b` | 7:1 (AAA large) |
| Body subtle | `#71717a` | `#0a0a0b` | 4.5:1 (AA) |
| Borders | `#27272a` | `#0a0a0b` | 1.4:1 (decorative) |
Dark mode borders are typically `rgba(255,255,255,0.08-0.12)` for subtle separation. Pure dark borders (`#000`) are invisible.
### Gradients
- Mesh gradients (multiple radial gradients overlaid) read more designed than two-stop linear gradients.
- Subtle: 2-3 stops with similar luminosity.
- Avoid pure purple-to-pink at 45deg (the AI-generated cliche).
- Brand-aware: use brand-adjacent colors (analogous on the color wheel).
### Atmosphere (subtle; dial down if it competes with content)
- Film grain: 1-3% opacity noise overlay.
- Soft radial gradient behind hero: off-center, low intensity.
- Decorative blur shapes outside the main content: 5-10% opacity.
- Dot or grid pattern: 4-8% opacity.
- Off-page large type ghost letters (the brand name as decorative background).
### prefers-reduced-transparency
Treat this query like `prefers-reduced-motion`: every translucent surface needs a flat fallback (opaque background, `backdrop-filter: none`, the same contrast ratio a contrast-sensitive user would get). The fallback is not "slightly less blur."
```css
.glass-card {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(12px);
}
@media (prefers-reduced-transparency: reduce) {
.glass-card {
background: var(--color-surface);
backdrop-filter: none;
}
}
```
## CSS Architecture
### @layer for design-system precedence
Cascade Layers replace specificity fights with one ordering rule: a later layer always wins, regardless of specificity.
```css
@layer reset, base, tokens, components, utilities;
@layer reset { /* normalize, your reset */ }
@layer base { /* element-level typography, body color, link defaults */ }
@layer tokens { :root { --brand: oklch(62% 0.18 270); } }
@layer components { .card { padding: var(--space-4); } }
@layer utilities { .m-0 { margin: 0; } }
```
- A high-specificity selector in `components` still loses to a low-specificity rule in `utilities`, so utilities reliably beat component CSS without `!important`. Specificity stays the tie-breaker inside a layer, not across layers.
- Anti-pattern: declaring layers without using them; a global unlayered `.card {}` beats a layered `@layer components { .card {} }`.
- Ceiling: five working layers; eight add mental overhead with no payoff.
- Put third-party CSS in its own layer at the right precedence via `@import url(...) layer(vendor);`; do not layer it into your component layer.
## Spacing and Composition
### Whitespace by lane
| Lane | Whitespace |
|------|-----------|
| Refined minimal / luxury | Very generous, sections separated by 96-128px |
| Editorial | Rhythmic, varied (some tight, some open) |
| Maximalist | Tight, deliberate density |
| Brutalist | Irregular; whitespace is a tool, not a default |
### Grids
- 12-column: most flexible default, works for almost everything.
- 8-column: tighter, denser layouts.
- 3, 5, 7: editorial or asymmetric layouts.
- No grid (bento, irregular): deliberately distinctive layouts.
### Asymmetry and grid-breaking
Break the grid for interest: a heading overlapping a column boundary, an image bleeding off the page edge, a diagonal flow, an element rotated 1-3 degrees. Use sparingly: one grid-break per section, not every section.
### Bento grid
A modular grid with variable card spans (one large hero card, two half-width, four quarter-width). Effective for multiple features at varying weights. Do not overdo it; bento does not fit every brand.
### Vertical rhythm
Lines of text should align to a baseline grid; set `line-height` so multiples create natural rhythm. With 16px body / 24px line-height, a 4 or 8 px spacing scale aligns naturally.
## Elevation and Shadow
Shadows convey depth, not decoration. Use a consistent scale.
### Light mode (worked examples)
| Level | Shadow |
|-------|--------|
| Resting card | `0 1px 2px 0 rgba(0,0,0,0.04)` |
| Hover card | `0 4px 12px -2px rgba(0,0,0,0.08), 0 2px 4px -1px rgba(0,0,0,0.04)` |
| Modal / overlay | `0 24px 48px -8px rgba(0,0,0,0.18), 0 12px 24px -8px rgba(0,0,0,0.10)` |
| Popover | `0 8px 24px -4px rgba(0,0,0,0.12), 0 4px 8px -2px rgba(0,0,0,0.06)` |
### Dark mode
Dark-mode shadows often look fake. Prefer:
- Lighter borders to indicate elevation instead of dark shadows.
- Subtle glow (1px outset shadow with a low-alpha brand color or white).
- Higher surface luminance for elevated elements (each step slightly lighter).
Tint dark-mode shadows with the brand color or a lifted neutral instead of pure black, alpha low (15 to 30 percent), so it reads as a colored haze rather than a black blob:
```css
:root[data-theme="dark"] {
--shadow-card:
0 4px 12px -2px color-mix(in oklch, var(--brand) 30%, transparent),
0 2px 4px -1px color-mix(in oklch, var(--brand) 20%, transparent);
}
```
### Shadow anti-patterns
- Drop shadows on every element.
- Shadows in random directions (some bottom-right, some bottom-left).
- Strong opacity (0.5+) that looks like gameplay UI.
### Border radius
Pick a scale: `0`, `4px`, `8px`, `12px`, `16px`, `24px`, `9999px` (pill). Use it consistently. By lane:
| Lane | Radius |
|------|--------|
| Brutalist | 0 throughout |
| Refined minimal | 8-12px cards, 6-8px buttons, full pill on tags |
| Playful | 16-24px on everything |
| Luxury | 0-4px (sharper) |
## Iconography
Pick one set and stay; do not mix.
| Source | Style |
|--------|-------|
| Lucide | Refined outline, 1.5px stroke, 24x24 grid. Free, open-source |
| Heroicons | Outline + Solid + Mini, by the Tailwind team. Free |
| Phosphor | Six weights from thin to fill. Many use cases. Free |
| Iconoir | Slightly more characterful outline. Free |
| Tabler | Largest free set, 24x24, 2px stroke |
| Feather | Original 24x24 outline (predecessor to Lucide) |
| Remix Icon | Many domains covered. Free |
| Iconjar / Streamline / Untitled UI Icons | Premium, distinctive |
Custom: even 6-8 custom icons in heroes/sections elevate a page beyond "Lucide on white".
## Imagery and Illustration
Photography:
- High quality, brand-relevant, original where possible.
- Consistent treatment (color grade, contrast, crop ratio).
- Avoid generic stock (the smiling business team in front of a window).
- Subjects diverse, contexts varied.
- Portrait: directional lighting, intentional background.
- Product: clean, consistent, scaled.
Illustration:
- Custom beats stock illustration packs.
- Consistent style (one illustrator's hand, or one style guide).
- Match the brand; cheerful illustrations do not fit a serious tool.
- Do not over-illustrate; one key illustration beats 12 mediocre ones.
3D:
- Works when it matches the brand, is rendered at high quality, and serves a purpose (a product "tour", a metaphor).
- Anti-pattern: a generic 3D blob or low-poly mascot dropped in for visual interest.
## Motion and Atmosphere
Full motion guide: see motion.md.
- One well-orchestrated entrance animation on page load (staggered reveals, hero parallax) beats scattered micro-interactions everywhere.
- Subtle ambient motion (a slow gradient shift, a hero element drifting 1-2px) gives life without distraction.
- Hover states with surprise (button text shifts forward, icon swaps direction, card lifts and tilts).
Match intensity to lane:
| Lane | Motion |
|------|--------|
| Brutalist | Minimal, snap transitions |
| Refined minimal | Subtle, slow, considered |
| Playful | Spring-physics, generous, characterful |
| Cyberpunk | Glitch, scan, jitter |
## Layout Patterns That Read as "Designed"
Versus the AI default (centered three cards over hero text with gradient):
- Asymmetric hero with the heading on the left and a visual on the right (or vice versa).
- Editorial split with content on the left third, large image on the right two-thirds.
- Bento grid features with variable card sizes.
- Side-scrolling features with a fixed left column and horizontally scrolling cards on the right.
- Diagonal flow sections (alternating left-aligned and right-aligned content).
- Overlapping hero elements where the visual partially covers the heading.
- Decorative type as a section divider (a giant ghost letterform behind the section).
- Sticky aside with active section indicator (long content with side nav).
- Horizontal timeline for narrative content.
- Step-by-step mockup showing the product changing state across the page.
## Dark Mode
Do not invert a light-mode design; plan dark mode as its own design pass.
- Surfaces: background deep, slightly tinted (not pure black, not pure gray), e.g., `#0B0B0F` or `#0F1117`. Surfaces are slightly lighter than the background (not the inverse of light mode); multiple elevation levels by lightening, not by shadow.
- Brand primary: often needs to be lighter and slightly desaturated in dark mode; test contrast.
- Accent text: a saturated accent that clears 4.5:1 against white commonly drops below 4.5:1 against near-black, so define a separate lightened accent token for text and links on dark surfaces and verify both modes independently.
- Imagery: photography in dark mode often needs a slight darken or color grade; illustrations may need dark-mode variants.
- State indicators: borders, focus rings, and hover states all need separate planning in dark mode.
## Brand Expression
Pick a level and commit; mid-level brand expression often reads as confused.
| Level | Treatment |
|-------|-----------|
| Restraint (Apple-like) | Mostly neutral, brand appears rarely (logo, accent CTAs), product content is the hero |
| Expression (Notion/Linear) | Brand color in CTAs and key surfaces, decorative brand elements present but not dominant |
| Saturation (indie/DTC) | Brand expressed everywhere, high saturation, distinctive type; the product is the brand |
## Voice
Visual design and verbal voice must match; a mismatch is the biggest tell a design is unfinished.
| Lane | Voice |
|------|-------|
| Refined | Precise, considered, never gimmicky |
| Editorial | Thoughtful, well-written, references |
| Playful | Human, warm, occasionally winking |
| Industrial | Technical, accurate, terse |
| Cyberpunk | Edgy, punchy, knows the audience |
## Atmospheric Details
The design reads "considered" when at least 5-10 of these exist:
- A custom cursor on a hero or feature surface (sparingly; never globally).
- Hover-reveal of a hidden detail (a tiny annotation appearing on a chart hover).
- A delightful 404 page with a working interaction.
- A hand-coded animation in the empty state (not a stock Lottie).
- A custom illustration in the onboarding.
- A unique scroll-progress indicator.
- A custom selection color matching the brand.
- A signature link underline (animated thickness, custom dash, or position).
- Custom error illustrations.
- Carefully written microcopy with personality.
## Reference Comparison Heuristics
When polishing against a reference, judge each as a yes-or-no, supported by captured screenshots at both audit viewports. A site at reference level passes every test; a site at "fine" level fails three or more. Authoritative use lives in the multi-page polish workflow (audit-workflow.md Phase 16); this is a lookup for design review outside that workflow.
1. First viewport has a clear hierarchy and does not feel accidental.
2. The brand or product is visible immediately when relevant.
3. The next section peeks intentionally rather than crowding the hero.
4. Repeated cards in a row align on top edges, bottom edges, and CTA baselines.
5. Text blocks have readable line lengths and consistent rhythm.
6. Labels, eyebrows, and badges use one visual language.
7. Buttons use one size system and one radius system.
8. Icons sit optically centered, not just mathematically centered.
9. Borders are subtle and consistent.
10. Shadows support hierarchy but do not muddy the palette.
11. Alternate background bands feel deliberate.
12. Mobile layout feels designed, not merely stacked.
13. Footer spacing is calmer than the body, with no stray gaps or cramped links.
14. Empty states, legal pages, and 404 pages share the same polish as marketing pages.
15. Same-family widgets look like members of one system across pages.
16. Variants are recognizably intentional, not accidental drift.
## Common Design Mistakes
- Two similar sans-serifs paired (no contrast).
- Centered everything (nothing leads the eye).
- Equal-weight feature cards (no hierarchy).
- Random spacing values (no rhythm).
- Mixing icon styles (loses cohesion).
- Drop shadows in random directions.
- Multiple gradient buttons of different colors.
- Light mode and dark mode designed at the same time as inversions.
- Decoration that distracts from content.
- Stock illustrations from the same pack everyone uses.
- Every section using the same layout (visual monotony).
- Every section using a different layout (visual chaos).
## Self-Healing for Design
Before declaring work complete:
- [ ] One aesthetic lane chosen and visible throughout.
- [ ] Maximum 2 type families, 4 weights, distinctive choices.
- [ ] One dominant color, 1-2 accents, semantic tokens defined.
- [ ] Spacing scale chosen and used consistently.
- [ ] Border-radius scale chosen and used consistently.
- [ ] Shadow/elevation scale chosen and used consistently.
- [ ] Iconography from one source, one stroke width, consistent sizes.
- [ ] Light AND dark mode designed independently.
- [ ] Motion language consistent (timing, easing, scope).
- [ ] At least one section breaks the safe centered layout.
- [ ] At least 3-5 atmospheric details that would not appear in an AI-default page.
- [ ] Voice matches visual.
## See Also
- [ui-ux.md](ui-ux.md) for the functional interaction rules.
- [motion.md](motion.md) for motion language.
- [responsive.md](responsive.md) for layout systems.
- [accessibility.md](accessibility.md) for contrast verification.
references/i18n.md
---
title: Internationalization and Localization
purpose: Translation pipelines, the Intl standard, plural rules, bidi correctness, locale-aware navigation, and the hreflang and canonical interaction that keep a multi-locale product coherent.
load-when:
task-keywords: [i18n, l10n, internationalization, localization, locale, translation, Intl, plural rules, bidi, RTL, mirroring, hreflang]
symptoms: [RTL broken, contrast fail, viewport overflow, horizontal scroll, hydration mismatch]
prereq: SKILL.md
related: [responsive.md, accessibility.md, seo.md, forms.md]
size: ~304 lines
---
# Internationalization and Localization
i18n is the engineering work that makes the product capable of being localized (extract strings, support bidi, use Intl): a codebase property. l10n is the per-locale content work (translate, vet, ship): a release artefact. Use this file when adding a locale, formatting per user preference, supporting an RTL script, debugging a translation-broken layout, or wiring hreflang.
## Translation pipeline UX
String extraction: the source is the catalogue, not the component.
- Every user-facing string lives in a resource file the translator can read, not in source code.
- Extract every string into a flat key namespace (`account.recovery.email_sent`) or a hierarchical structure the translation tool understands.
- Keys are stable; strings are translatable. Renaming a key invalidates every translation.
- Comments alongside the key give the translator context ("Button label on the recovery confirmation modal.").
- Screenshots attached to the key remove ambiguity for non-native engineers and translators.
Missing-key fallback by environment:
| Environment | Render |
|-------------|--------|
| Production | Default-locale string silently. Never the raw key (`account.recovery.email_sent`). |
| Development | The key in a high-visibility wrapper so engineers spot it (`[[account.recovery.email_sent]]`). |
| Staging | The key alongside the default-locale string so QA can confirm coverage before launch. |
Length expansion buffer (test with the longest reasonable translation, not the English original):
| Language | Length vs English |
|----------|-------------------|
| German | +30% on average; some technical terms +60% |
| Russian, Polish | +20% |
| Chinese, Japanese | shrink to ~70%, but glyphs are denser |
- Buttons reserve 30% extra horizontal room (a "Save" button at 60 pixels in English must fit "Speichern" at 80 pixels in German).
- Form labels wrap to two lines without breaking layout; labels next to inputs reserve the wrap height.
- Card titles allow at least one extra line: two-line max on every card, three on long-form locales.
- Table cells holding action labels grow with the label, not against it.
Coverage gate:
- A missing key in a locale fails the build or blocks the deploy; the pipeline reports missing keys per locale on every PR.
- A release candidate at 92% translation coverage ships at 92% only if the team has explicitly accepted the gap.
## Intl at depth
Use the `Intl` namespace as the standard instead of any project-specific date or number formatter. Constructors are cheap; memoise per locale.
### Intl.NumberFormat
Use for currency, percent, units, and decimal display. Locale-aware decimal separator, grouping separator, currency placement, and unit pluralisation come for free.
```js
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(1234.56); // "1.234,56 €"
new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 1 }).format(0.078); // "7.8%"
new Intl.NumberFormat("en-US", { style: "unit", unit: "kilometer" }).format(42); // "42 km"
```
- The currency constructor takes a currency code (`USD`, `EUR`), not a symbol; the symbol is rendered per locale.
- Pick either fraction digits (`minimumFractionDigits` plus `maximumFractionDigits`) or significant digits (`minimumSignificantDigits` plus `maximumSignificantDigits`), not both.
- `useGrouping: false` disables the grouping separator (useful for IDs that look like numbers).
### Intl.DateTimeFormat
Use for any user-visible date or time. Pass an explicit `timeZone` for server-generated values; let the constructor infer the user's zone for client-side values.
```js
new Intl.DateTimeFormat("ja-JP", { dateStyle: "long", timeZone: "Asia/Tokyo" }).format(new Date());
// locale long date in Tokyo time
```
- `dateStyle` and `timeStyle` accept `full`, `long`, `medium`, `short` and pick a locale-appropriate ordering and separator.
- `era` (`short`, `long`) adds AD or BC, useful for historical content.
- `calendar` (`gregory`, `japanese`, `buddhist`, `hebrew`, `islamic`) overrides the locale's default calendar.
- For relative times ("2 days ago") use `Intl.RelativeTimeFormat`, not a string built from `DateTimeFormat`.
### Intl.PluralRules
Returns the plural category for a value in a locale: `zero`, `one`, `two`, `few`, `many`, `other`. English uses two, Arabic uses six.
```js
const pr = new Intl.PluralRules("ar");
pr.select(0); // "zero"
pr.select(1); // "one"
pr.select(2); // "two"
pr.select(11); // "many"
```
Never write `count === 1 ? "item" : "items"`; it breaks on every locale with more than two categories. Use ICU MessageFormat, Fluent, or a key-per-category table.
### Intl.ListFormat
Joins items into a locale-aware list ("a, b, and c" in English; " et " between the last two items in French).
```js
new Intl.ListFormat("en", { style: "long", type: "conjunction" }).format(["red", "green", "blue"]);
// "red, green, and blue"
new Intl.ListFormat("en", { style: "long", type: "disjunction" }).format(["red", "green", "blue"]);
// "red, green, or blue"
```
Types: `conjunction` (and), `disjunction` (or), `unit` (no joiner, for unit lists). Styles: `long`, `short`, `narrow`.
### Intl.RelativeTimeFormat
Formats relative times in the user's locale with the right preposition and tense.
```js
new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(-1, "day"); // "yesterday"
new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(2, "hour"); // "in 2 hours"
new Intl.RelativeTimeFormat("fr", { numeric: "auto" }).format(-1, "day"); // "hier"
```
- `numeric: "auto"` gives the natural-language form ("yesterday"); `numeric: "always"` gives the numeric form ("1 day ago").
- Compute the delta and pick the unit (second, minute, hour, day, week, month, year) yourself; `RelativeTimeFormat` does the formatting, not the bucketing.
### Intl.Segmenter
Splits text into grapheme clusters, words, or sentences with locale-aware boundary detection. Critical for languages without spaces (Japanese, Chinese, Thai).
```js
const seg = new Intl.Segmenter("ja", { granularity: "word" });
const segments = [...seg.segment("これは日本語の文章です。")];
// each .segment is a word; .isWordLike distinguishes words from punctuation
```
The default `String.prototype.split(/\s+/)` is wrong for any CJK script. Use `Intl.Segmenter` for word count, locale-aware truncation, search highlighting, and sentence-level summarisation.
## Plural rules in depth
Plural categories per locale, from CLDR:
| Locale | Categories |
|--------|-----------|
| English | `one`, `other` (2) |
| French | `one`, `many`, `other` (3) |
| Russian | `one`, `few`, `many`, `other` (4) |
| Polish | `one`, `few`, `many`, `other` (4) |
| Arabic | `zero`, `one`, `two`, `few`, `many`, `other` (6) |
| Welsh | `zero`, `one`, `two`, `few`, `many`, `other` (6) |
| Japanese | `other` (1) |
| Chinese | `other` (1) |
Anti-patterns:
- `count === 1 ? "item" : "items"`: wrong on every locale with more than two categories.
- `count > 1 ? plural : singular`: wrong for zero, and for locales with a separate `zero` category.
- Concatenating "(s)": reads as a typo, does not translate.
- Picking the plural in code, not in the translation: the translator cannot fix what they cannot see.
Correct shape:
```js
const category = new Intl.PluralRules(locale).select(count); // "one", "other", "few", etc.
const message = strings[`cart.items.${category}`].replace("{count}", count);
```
Or ICU MessageFormat in the resource file:
```text
cart.items = {count, plural, =0 {Your cart is empty} one {# item} other {# items}}
```
ICU `=0` exact-match special-cases zero independently of the category; CLDR `zero` covers a different concept (some Slavic languages use `zero` for numbers ending in 0).
## Bidi correctness beyond dir="rtl"
Adding `dir="rtl"` to `<html>` swaps writing direction but leaves three classes of bug: mirrored icons, directional CSS properties, navigation order.
Mirrored icons:
- CSS mirror via a `dir` logical selector for a single flippable icon: `[dir="rtl"] .icon-chevron-back { transform: scaleX(-1); }`.
- Ship pre-mirrored SVG sets (`chevron-back-ltr.svg`, `chevron-back-rtl.svg`) and pick per direction when the visual asymmetry is meaningful (a hand-drawn arrow that does not reflect cleanly).
- Symmetric icons (search, settings, close) need no mirroring; audit the icon set once and mark the directional ones.
- Do not mirror: branded logos, country flags, content images, math notation, time-of-day icons (a clock face), media-control icons (play, pause, stop).
Logical properties (these flip with `dir="rtl"` automatically; directional properties do not):
| Directional (old) | Logical (new) |
|-------------------|---------------|
| `margin-left` | `margin-inline-start` |
| `margin-right` | `margin-inline-end` |
| `padding-left` | `padding-inline-start` |
| `padding-right` | `padding-inline-end` |
| `left: 0` | `inset-inline-start: 0` |
| `right: 0` | `inset-inline-end: 0` |
| `border-left` | `border-inline-start` |
| `text-align: left` | `text-align: start` |
| `float: left` | `float: inline-start` |
Navigation order in RTL:
- The back button lives on the right side of a top bar; the form's primary action lives on the left. The "back is behind me" mental model is mirrored.
- Replace hardcoded `position: absolute; left: 1rem` for a back chevron with `inset-inline-start`.
- In RTL the left arrow advances and the right arrow goes back. Bind keyboard handlers to logical concepts (`next`, `previous`), not physical keys, and route via the `dir` attribute.
Bidi isolation:
- Wrap user-entered content that may contain bidi characters (usernames, addresses, free-form titles) in `<bdi>` to isolate the embedded run from surrounding directionality: `<p>The author is <bdi>{userName}</bdi>.</p>`.
- For programmatic isolation in non-HTML contexts, wrap a string with the Unicode bidi isolate characters `U+2068` and `U+2069`.
## Locale-aware navigation
Arrow keys in a horizontal scroll container:
- LTR: right arrow advances, left arrow goes back. RTL: left arrow advances, right arrow goes back.
- Page Up, Page Down, Home, and End are logical and do not flip with direction.
Route arrow keys through a `dir`-aware adapter:
```js
const dir = document.documentElement.dir || "ltr";
const advance = dir === "rtl" ? "ArrowLeft" : "ArrowRight";
const reverse = dir === "rtl" ? "ArrowRight" : "ArrowLeft";
```
Carousels and forms:
- A carousel's next slide moves in the locale's reading direction, so in RTL the next slide enters from the left. Custom carousels must invert their swipe handlers per `dir`.
- Place Submit at the inline end and Cancel at the inline start using `flex-direction: row` with logical justification. Do not hardcode `justify-content: flex-end` or position buttons by left and right.
## hreflang and canonical interaction
The bidirectional rule: every locale references every other locale plus `x-default` for the fallback. Five locales means five `<link rel="alternate" hreflang>` entries on every page, one referencing the page's own URL. If page A references page B but B does not reference A, search engines drop the cluster: the cluster is symmetric or it is broken. (Deeper canonical treatment: see seo.md.)
```html
<link rel="alternate" hreflang="en-US" href="https://HOST/en/" />
<link rel="alternate" hreflang="de-DE" href="https://HOST/de/" />
<link rel="alternate" hreflang="ja-JP" href="https://HOST/ja/" />
<link rel="alternate" hreflang="x-default" href="https://HOST/en/" />
<link rel="canonical" href="https://HOST/en/" />
```
- The canonical chain: every locale page's canonical points at its own locale URL, not the default locale's. Cross-locale canonicalisation is almost always wrong.
- The exception: a duplicate-content page that should not rank in its own right (a print version, a `?utm=` variant) canonicalises to the indexable version. Default to "each locale canonicalises to itself."
- `x-default` is the best-guess fallback (typically the default locale, English) for a user whose locale is not in the set; it is a hint treated as one input among many.
- If the site uses geolocation to pick a locale, `x-default` should point at the locale-selection page, not at any specific locale.
## Translation-pipeline operational notes
Tool choice (pick by where the bottleneck lives):
| Tool | Strength | Weakness | Bottleneck |
|------|----------|----------|------------|
| Crowdin | In-context preview, translator-friendly | Per-string pricing scales fast | Translators |
| Lokalise | Strong CLI and API | Less polished translator UX | Engineers |
| Phrase | Versioning and branching parity with code | Heavier UI, learning curve | Localization managers |
| Open-source (Weblate, Pontoon) | Self-hosted, no per-seat fee | Requires ops capacity | Budget |
Format choice:
| Format | Strength | Weakness | Use when |
|--------|----------|----------|----------|
| ICU MessageFormat | Most expressive plural and select syntax, tooling support | Slight authoring complexity | Default |
| Fluent (Mozilla) | Multi-line, expressive, designed for translation | Smaller ecosystem | Grammatical agreement (gender, case) matters in many locales |
| GNU gettext (`.po`) | Massive ecosystem, every locale tool supports it | Limited plural syntax, poor select | Integrating with a legacy chain |
| Flat JSON | Trivial, works everywhere | Plural handling is per-key duplication | Simplest needs |
## Locale negotiation
Two signals: the `Accept-Language` header (passive, from the browser) and an explicit user choice (active, from a settings menu or URL prefix).
- An explicit user choice always wins; never override it with `Accept-Language`.
- Persist the choice in a cookie the server can read on the next request.
- The first visit (no cookie) uses `Accept-Language` to pick a default locale.
- If `Accept-Language` resolves to an unsupported locale, fall back to the default locale and surface the available locales prominently.
- The URL is canonical: locale lives in the path (`/en/`, `/de/`) or the subdomain (`en.` prefix). The cookie persists the choice; the URL communicates it.
- Never block on `Accept-Language` resolution; render the default locale immediately and switch after the cookie is read if needed.
## Currency display
Currencies share symbols across countries: `$` is USD, CAD, AUD, NZD, MXN and twenty more; `£` is GBP and EGP; `kr` is at least four currencies.
- Show the currency code alongside an ambiguous symbol on first reference ("USD$ 12.99", "CAD$ 12.99"); on second reference within the same view the symbol alone is enough.
- For prices in the user's own currency the symbol-only form is usually fine; the user already knows the currency.
- For prices that vary by region (a marketplace, a checkout flow), default to the disambiguated form everywhere.
- `Intl.NumberFormat` with `style: "currency"` and the right currency code does the right thing per locale; pass the code, not the symbol.
## Numbers in forms
| Separator | Locales | Renders 1234.56 as |
|-----------|---------|--------------------|
| Period | en-US, en-GB, ja-JP, zh-CN | `1,234.56` |
| Comma | de-DE, fr-FR, es-ES, pt-BR, ru-RU | `1.234,56` |
| Space / none | some technical contexts | grouping or decimal varies |
- Parse user numeric input in the user's locale using `Intl.NumberFormat.formatToParts` to expose the separators for reverse-parsing.
- Accept both decimal forms for copy-paste forgiveness; a user pasting `1234.56` into a de-DE form should not fail validation.
- On `<input type="number">` the browser handles locale-aware input itself; use `<input inputmode="decimal">` on text inputs for prices to bring up the right mobile keyboard.
- Store and transmit numbers as locale-neutral (period as decimal, no grouping); format only on display.
## See Also
- [responsive.md](responsive.md): logical-property layout and breakpoints that survive translation.
- [accessibility.md](accessibility.md): screen-reader and keyboard rules that bidi navigation depends on.
- [seo.md](seo.md): the deeper canonical and hreflang treatment.
- [forms.md](forms.md): form-level rules that combine with locale-aware numeric input.
references/forms.md
---
title: Forms and Feedback
purpose: Input-level form discipline (label, type, validation, autocomplete), the constraintValidation API, field-level abandonment analytics, and address autofill depth. Flow-level auth lives in auth.md.
load-when:
task-keywords: [form, validation, autofill, autocomplete, label, input, select, checkbox, radio, file upload, constraintValidation, touch target]
symptoms: [contrast fail, focus not visible, duplicate id]
prereq: SKILL.md
related: [accessibility.md, ui-ux.md, motion.md, auth.md]
size: ~552 lines
---
# Forms and Feedback
Framework-agnostic patterns for forms that are easy to fill, easy to recover from, and accessible. Forms are where most products earn or lose users.
## Form Anatomy
```
[ Optional: page heading ]
[ Optional: short context: why we're asking ]
[ Field group ]
[ Label (always visible) ] [ Optional indicator: required, optional ]
[ Input ]
[ Helper text (always when complex) ]
[ Error message (when invalid) ]
[ ... more field groups ]
[ Submit button ] [ Cancel / secondary action ]
```
## Labels
| Principle | Concrete check |
| --- | --- |
| Always visible | Label visible at all times, not just when the field is empty. |
| Associate | Wrap or associate: `<label for="email">Email</label> <input id="email" />` or `<label>Email <input /></label>`. |
| Float labels | Acceptable visually (label transitions to a small position when focused/filled) but the label must stay associated with the input. |
| Placeholder is not a label | The placeholder disappears when typing and most placeholders fail contrast. |
| Position default | Top-aligned is the default; easiest to scan. |
| Position dense | Left-aligned for compact dense forms (settings). |
| Position rare | Right-aligned rarely; harder to scan. |
| Mark the smaller set | If most fields are required, mark the optional ones, not the larger set. |
| Required indicator | Use both visual (asterisk) AND text (the word "required" somewhere visible). |
| Required programmatic | Use `aria-required="true"` (or the native `required` attribute, which implies it). |
Required field markup:
```html
<label for="email">
Email <span aria-hidden="true" class="required">*</span>
</label>
<input id="email" type="email" required aria-required="true" />
```
## Input Types
Use the right `type` and `inputmode` for the data:
| Data | type | inputmode | autocomplete |
| --- | --- | --- | --- |
| Email | email | email | email |
| Phone | tel | tel | tel |
| URL | url | url | url |
| Numeric ID, code | text | numeric | one-time-code (for OTP) |
| Money | text | decimal | (use a formatted text input) |
| Date | date | (auto) | bday or other |
| Search | search | search | (none) |
| Password | password | (auto) | new-password / current-password |
| Name | text | text | given-name / family-name / name |
| Address | text | text | street-address, address-line1, postal-code, country |
| Credit card | text | numeric | cc-number / cc-exp / cc-csc / cc-name |
### autocomplete is critical
Set `autocomplete` so browsers and password managers fill fields automatically; without it users must type or copy-paste.
WHATWG `autocomplete` name tokens (commonly used):
- Name: `name`, `given-name`, `additional-name`, `family-name`.
- Contact: `email`, `tel`, `tel-national`.
- Address: `street-address`, `address-line1`, `address-line2`, `address-level1` (state), `address-level2` (city), `postal-code`, `country`.
- Credit card: `cc-name`, `cc-number`, `cc-exp`, `cc-csc`, `cc-type`.
- Auth: `username`, `current-password`, `new-password`, `one-time-code`.
- Organization: `organization`, `organization-title`.
- Birthday: `bday`, `bday-day`, `bday-month`, `bday-year`.
On multi-form pages, prefix the value with the form purpose (`shipping`, `billing`):
```html
<input autocomplete="shipping street-address" />
<input autocomplete="billing street-address" />
```
### Address autofill: structured vs single-line
| Choice | Check |
| --- | --- |
| Structured (multi-field) | Separate fields for `address-line1`, `address-line2`, `address-level2` (city), `address-level1` (state/region), `postal-code`, `country`, with `country` FIRST so downstream fields adapt; best for shipping and billing. |
| Single-line | One `<input autocomplete="street-address">` holding the whole address; use only when you do not need the components separately. |
International address shapes vary; do not hard-code one country's shape:
- Some countries (Ireland, the UAE, Hong Kong before Eircode) have no postal code at all, so `postal-code` cannot be `required` for those countries.
- Some countries (Japan, China) write the address from the largest unit to the smallest (prefecture, city, street); UI field order should mirror that for the chosen country.
- Some countries (the UK, India) use `address-level2` (city) plus `address-level1` (county/state) that the user often leaves blank; do not block submission.
- A `country` field that adapts the downstream form is the simplest path to international correctness; drive label, required-flag, and regex per country from a single locale config.
`street-address` granularity: the WHATWG list breaks it into `address-line1`, `address-line2`, `address-line3`, `address-level1` through `address-level4`, `postal-code`, `country`, `country-name`. Declaring `street-address` on one line is valid and declaring the broken-down fields is valid, but mixing the two on the same form confuses autofill; pick a level and commit.
Two suggestion sources:
- Browser autofill uses the user's saved addresses from their browser or password manager; cost zero; triggers when the `autocomplete` attribute matches a known field; best for returning/known users.
- A geocoding service suggests addresses as the user types; cost a per-request API fee; triggers via explicit JS integration; best for first-time customers and catching typos.
Coexistence: let browser autofill run on focus, then surface geocoding suggestions as the user keeps typing. Do NOT disable browser autofill to force geocoding; `autocomplete="off"` is widely ignored by browsers and even when honoured it hurts password managers.
### Native vs custom controls
Native first:
- `<input type="date">` is fine on mobile; custom date pickers are appropriate for desktop ranges.
- Style `<input type="checkbox">` and `<input type="radio">` with `:checked` selectors plus pseudo-elements; use the ARIA pattern only when truly necessary.
- `<input type="range">` for sliders.
- `<select>` for short lists; combobox for long or async lists.
- `<input type="file">` for uploads; style the trigger with a `<label>` to match design.
Custom controls require: full keyboard support (arrow keys, Enter/Space, Esc); ARIA role and state; focus management; touch and mouse parity.
## Validation
### When to validate
| Trigger | Rule |
| --- | --- |
| Every keystroke | Don't; it's distracting and often wrong before enough is typed. |
| On blur | Validate when the user leaves the field. |
| On submit | Re-validate as a safety net; the server validates regardless. |
| Password strength meter | Update on keystroke since the user expects live feedback. |
| Confirm-password | Validate when the second field loses focus. |
### constraintValidation API
Every form control exposes a `ValidityState` object via `input.validity`; read its flags and set custom messages instead of hand-rolling parallel validation state.
| Flag | Meaning |
| --- | --- |
| `valueMissing` | `required` is set and the value is empty. |
| `typeMismatch` | Value does not match `type` (a `type="email"` with no `@`, a `type="url"` with no scheme). |
| `patternMismatch` | Value does not match `pattern`. |
| `tooShort` / `tooLong` | Outside `minlength` / `maxlength`. |
| `rangeUnderflow` / `rangeOverflow` | Outside `min` / `max`. |
| `stepMismatch` | Not aligned with `step`. |
| `badInput` | Browser cannot parse the value (a non-number in `type="number"`). |
| `customError` | A previous `setCustomValidity()` call set a message. |
```js
const email = document.querySelector('#email');
email.addEventListener('input', () => {
if (email.validity.typeMismatch) {
email.setCustomValidity('Enter an email like name@your-domain.com');
} else if (email.validity.valueMissing) {
email.setCustomValidity('Email is required');
} else {
email.setCustomValidity(''); // clear; the field is valid
}
});
email.form.addEventListener('submit', (event) => {
if (!email.form.checkValidity()) {
event.preventDefault();
email.form.reportValidity(); // shows browser UI for invalid fields
}
});
```
The `invalid` event fires per field when the form fails to validate on submit; listen for it to render custom error UI. Never rely on the browser's default tooltip alone: it disappears on focus move, has no contrast guarantees, is not announced by every screen reader, and cannot be styled.
### Error placement
- Inline, below the field, because the error is about THIS field.
- `aria-invalid="true"` on the input for programmatic error state.
- `aria-describedby` on the input pointing to the error message id.
- `role="alert"` on the error so screen readers announce it.
```html
<label for="email">Email</label>
<input
id="email"
type="email"
required
aria-invalid="true"
aria-describedby="email-error"
/>
<p id="email-error" role="alert" class="error">
Enter a valid email address (example: name@example.com)
</p>
```
### Error message content
- State cause AND fix: "Email is required" beats "Validation error"; "Enter a valid email address" beats "Invalid".
- Plain language; don't expose backend codes ("ERR_INVALID_INPUT"), translate them.
- Don't blame the user: "Couldn't save your draft" not "You haven't filled everything in".
- Stay positive when possible: "Try a longer password" beats "Password too short".
### Multi-error submission
- Move focus to the first invalid field (or to a top-of-form summary).
- Show a summary at the top with anchor links to each error.
- Each invalid field also shows its inline error.
```html
<div role="alert" aria-live="assertive" class="error-summary">
<h2>3 issues to fix:</h2>
<ul>
<li><a href="#email">Enter a valid email</a></li>
<li><a href="#password">Password must be at least 8 characters</a></li>
<li><a href="#terms">Accept the terms to continue</a></li>
</ul>
</div>
```
### Server-side errors
- Show inline near the relevant field if known.
- Show a top-of-form alert if the error doesn't map to a field (network failure).
- Provide a recovery action (retry, contact support).
- Preserve user input; never make them retype.
## Helper Text
Persistent text below the field, before any error:
- Format expectations ("3 to 30 characters, letters and numbers").
- Why we're asking ("Used for password reset only; never shown publicly").
- Examples ("e.g., +1 555 123 4567").
Place between input and error; when error shows, error replaces or appears below helper.
## Submit Button
| Concern | Check |
| --- | --- |
| Location | Primary submit at the bottom, aligned to the form's primary text direction. |
| Mobile | Full-width is acceptable for the primary submit. |
| Label | Specific verb-noun: "Create account" beats "Submit", "Save draft" beats "Save". |
| Label match | Match the action: "Send invoice" if that's what happens. |
| Double-submit | Disable while submitting to prevent double-submit. |
| Spinner | Show a spinner inside the button during submission. |
| No shift | Maintain button width to prevent layout shift. |
| Live label | Optionally update the label ("Creating account" instead of "Create account"). |
```html
<button type="submit" disabled aria-busy="true">
<span class="spinner" aria-hidden="true"></span>
Creating account
</button>
```
### Cancel
Provide a secondary action (cancel, back) when the user might back out. Confirm if there are unsaved changes:
```html
<button type="button" onclick="confirmExit()">Cancel</button>
```
```js
function confirmExit() {
if (formIsDirty()) {
if (confirm('Discard your changes?')) {
// navigate away
}
} else {
// navigate away
}
}
```
## Multi-Step Forms
Stepper:
```
[Step 1: Account] -> [Step 2: Profile] -> [Step 3: Payment]
Done In progress Upcoming
```
- Current step visually distinct.
- Completed steps clickable (allow back navigation).
- Upcoming steps not clickable.
Save state and validation:
- Auto-save draft at each step transition.
- Restore state on return.
- Allow exit without losing data.
- Validate the current step on "Next".
- Don't validate steps the user hasn't reached.
- Show validation errors before advancing.
## Long Forms
For very long forms (> 10 fields):
- Group fields with `<fieldset>` + `<legend>`:
```html
<fieldset>
<legend>Shipping address</legend>
<!-- fields -->
</fieldset>
```
- Use visual section headings with horizontal lines or background variation.
- Auto-save drafts every 30-60s or on blur of significant fields.
- Show a "Saved 5s ago" microcopy.
Autosave UX:
- Don't interrupt the user's typing with the save indicator.
- Show status in a fixed corner ("Saving...", "Saved", "Failed to save - retry?").
- Use `aria-live="polite"` so screen readers announce changes without interrupting.
## Form Analytics and Field-Level Abandonment
Form-level conversion (submitted vs viewed) tells you the form is broken; field-level analytics tells you WHICH field. Instrument these events per field and compute the funnel:
| Event | Signal |
| --- | --- |
| Focus | The user reached the field; logs which fields are attempted. |
| Blur with value | The user filled the field and moved on. |
| Blur without value | The user touched the field, did not fill it, moved on: the abandonment signal. |
| Submit attempt with this field invalid | The user tried to submit and this field rejected. |
```js
function instrumentField(field) {
let focusedAt = 0;
field.addEventListener('focus', () => {
focusedAt = performance.now();
track('field_focus', { name: field.name });
});
field.addEventListener('blur', () => {
const filled = field.value.trim().length > 0;
track('field_blur', {
name: field.name,
filled,
dwellMs: Math.round(performance.now() - focusedAt),
});
});
}
document.querySelectorAll('form input, form select, form textarea').forEach(instrumentField);
```
Common findings:
- A `phone` field with 40 percent blur-without-value is the abandonment culprit; consider making it optional or removing it.
- A `birthday` field with high focus-then-leave looks invasive; explain why you need it inline.
- A `password` field with high "submit attempt with this field invalid" means the strength rule is harsh; surface the rule before submission.
- A field with high re-focus count means format confusion; add helper text.
Send events via `navigator.sendBeacon` so abandonment events survive the unload; sample if the form sees high traffic. See observability.md for the RUM pipeline.
## Confirmation and Destructive Actions
Confirm before destructive ("Delete account", "Cancel subscription", "Discard draft"):
- Require an explicit confirmation.
- Modal with a clear title ("Delete account?").
- Body states consequences ("This permanently deletes your account and all data. This cannot be undone.").
- Two buttons: "Cancel" (primary) and the destructive action (destructive secondary, danger color).
- For very destructive actions, require typing the resource name to confirm ("Type your account email to confirm").
Don't confirm trivial actions:
- Don't confirm "Save" (non-destructive).
- Don't confirm "Send message" unless the user requested confirm-before-send.
- Don't confirm form resets if "Cancel" already exists.
Undo: for undoable destructive actions prefer undo over confirm (toast "Item deleted. Undo (5s)"); it is faster, less interruptive, and works with screen readers. Use confirm only for actions that genuinely cannot be undone (account deletion, payment).
## File Upload
- Show a drop zone (visible on hover/dragover).
- Click-to-browse via a styled `<label for="file-input">`.
- Show a thumbnail/preview of selected files.
- Show a progress bar during upload.
- Allow cancel during upload.
- Show an error per file (size, type, network).
- Accept multiple files where appropriate.
```html
<label for="resume" class="dropzone">
<input id="resume" type="file" accept=".pdf,.doc,.docx" hidden />
Drag and drop your resume, or click to browse.
PDF, DOC, or DOCX, up to 5 MB.
</label>
```
Hide the native input; style the label as the drop zone.
## Search Forms
- Single field with a magnifier icon.
- Submit on Enter (native `<form>` behavior).
- Optionally submit on type with a 300ms debounce.
- Show recent searches when empty.
- Show suggestions as the user types.
- Show a clear-input button when populated.
- Show results in real-time or after submit.
```html
<form role="search" action="/search">
<label for="q" class="sr-only">Search</label>
<input id="q" name="q" type="search" autocomplete="off" />
<button type="submit">Search</button>
</form>
```
`role="search"` on the form is a landmark for screen readers.
## Modern Auth Pointer
This file keeps INPUT-level concerns: the `<input>` element, its label, its validation, its autofill hint, its error message. Flow-level auth concerns (passkeys and WebAuthn, conditional UI, OAuth redirect UX, magic-link flow, account recovery, session-expiry, CAPTCHA placement) live in auth.md. If the question is "how should this input be marked up and validated", look here; if it is "how should this whole sign-in flow behave", look in auth.md.
## Login and Sign-Up
```html
<form>
<label for="username">Email</label>
<input id="username" name="username" type="email" autocomplete="username" required />
<label for="current-password">Password</label>
<input id="current-password" name="password" type="password" autocomplete="current-password" required />
<button type="submit">Sign in</button>
<a href="/forgot">Forgot password?</a>
<a href="/signup">Create account</a>
</form>
```
- Email/username must use `autocomplete="username"` (counterintuitive, but the spec).
- Login password must use `autocomplete="current-password"`.
- Always use a `<form>` (not just floating fields) so password managers detect it.
Sign-up:
- New password uses `autocomplete="new-password"`.
- Show password strength inline.
- Show a "Show / hide" toggle for the password field.
- After submit, send to verification or onboarding, not back to a login screen.
Password reset:
- Email field uses `autocomplete="username"`.
- Submit reveals "Check your email" regardless of whether the email exists (security best practice).
- The reset link page uses `autocomplete="new-password"`.
One-time codes:
- `autocomplete="one-time-code"` so mobile suggests codes from SMS.
- `inputmode="numeric"` for numeric codes.
- `pattern="\d*"` for native validation on numeric codes.
- Auto-submit when the expected number of digits is entered.
## Payment Forms
- Card number: `autocomplete="cc-number"`, `inputmode="numeric"`, `pattern` for digits.
- Expiry: `autocomplete="cc-exp"`, `inputmode="numeric"`.
- CVC: `autocomplete="cc-csc"`, `inputmode="numeric"`, `maxlength="4"`.
- Cardholder name: `autocomplete="cc-name"`.
- Address autofill: `autocomplete="postal-code"` etc.
- Use a hosted payment element or platform payment request API in production; never collect raw card data.
## Mobile-Specific
- Set the right `inputmode` and `type` so the right mobile keyboard appears.
- For numeric codes, `inputmode="numeric"` shows the number pad on mobile.
- For dates, `<input type="date">` shows the native date picker on most platforms.
- Mobile browsers auto-zoom when an input has font-size below 16px; set inputs to >= 16px to prevent this.
- Inputs at least 44px tall (`height: 44px` or sufficient padding); same for buttons. See accessibility.md for hit-target detail.
Sticky submit bar (with safe-area padding):
```css
.sticky-submit {
position: sticky;
bottom: 0;
padding-bottom: max(16px, env(safe-area-inset-bottom));
}
```
## Form Accessibility
- Every input has a programmatic label.
- Required fields are marked.
- Error messages associated via `aria-describedby`.
- Errors announced via `role="alert"` or `aria-live`.
- Focus moves to first invalid field on submit error.
- Group with `<fieldset>` + `<legend>`.
- Use `<form>` so password managers and screen reader landmarks detect it.
## Common Form Mistakes
- Placeholder as label.
- No `autocomplete` attributes.
- Placeholders that look like real values ("name@example.com") confused with filled state.
- Validating on every keystroke.
- Errors only at the top, no inline indication.
- Generic errors ("Invalid", "Error").
- "Required" indicator on all fields when most are required.
- Submit button labeled "Submit".
- Disabled submit when fields are empty (instead, validate on submit attempt and show errors).
- No loading state on submit; user clicks twice and creates duplicates.
- No autosave on long forms.
- Fields without `type` (default `type="text"` shows the wrong keyboard on mobile).
- Asking for unnecessary data ("Why does signup need my phone?").
- Multi-line text in a single-line input.
- Cancel button styled as primary, submit as secondary.
## Self-Healing for Forms
Before declaring work complete:
- [ ] Every input has a visible, programmatic label
- [ ] Right `type` and `inputmode` for the data
- [ ] `autocomplete` set per WHATWG spec
- [ ] Required fields marked visually + `aria-required`
- [ ] Errors inline, near the field, with cause + fix
- [ ] Errors announced via `role="alert"` or `aria-live`
- [ ] On submit error, focus moves to first invalid field
- [ ] Submit button has loading state, disabled during submission
- [ ] Submit button label is specific (verb-noun)
- [ ] Helper text for complex fields
- [ ] Long forms autosave; "Saved" indicator visible
- [ ] Multi-step forms show progress and allow back navigation
- [ ] Destructive actions confirm or provide undo
- [ ] Mobile: 16px+ font size, 44px+ touch targets
- [ ] `<form>` element used; password manager detects fields
- [ ] Tested with keyboard only, screen reader, and password manager
## See Also
- accessibility.md for input accessibility and hit-target detail
- ui-ux.md for state design (loading, error, success)
- motion.md for transitions on validation feedback
- auth.md for flow-level auth (passkeys, OAuth, magic link, recovery)
references/lighthouse.md
---
title: Lighthouse Mastery
purpose: Score-driven audit playbook mapping every common failing Lighthouse audit to root cause and concrete fix, plus CI gate wiring, phantom-failure triage, and field-vs-lab cross-checks.
load-when:
task-keywords: [lighthouse, audit, lighthouse-ci, performance, errors-in-console, image-size-responsive, diagnostic Insights, phantom failure, CSP, SRI, gate, CrUX]
symptoms: [score dropped, Lighthouse score drop, LCP regression, CLS regression, phantom dev failure, errors-in-console, image too small on retina, stale SRI beacon]
prereq: SKILL.md
related: [performance.md, accessibility.md, seo.md, debug-recipes.md]
size: ~384 lines
---
# Lighthouse Mastery
Framework-agnostic playbook for hitting and holding the top of the Lighthouse curve. Every audit below is reachable from `lighthouse --output=json` or the DevTools Lighthouse panel.
## Score Targets
| Category | Target |
|----------|--------|
| Performance (mobile) | >= 95 |
| Performance (desktop) | >= 99 |
| Accessibility | 100 |
| Best Practices | 100 |
| SEO | 100 (n/a if `noindex`) |
- A 99 hides one or two regressions; only 100 means "no audits failed". Demand 100 for Accessibility, Best Practices, and SEO unless an audit is genuinely inapplicable.
- Hold Performance at 95 mobile and 99 desktop. A relaxation (a heavy interactive tool that legitimately ships more JS) must be a recorded, justified exception, never a default.
## How Lighthouse Computes Performance
Weighted sum of lab metrics, scaled against the public-web distribution (Lighthouse 11+):
| Metric | Weight | Measures |
|--------|-------:|----------|
| Largest Contentful Paint (LCP) | 25% | When the largest visible element finished painting |
| Total Blocking Time (TBT) | 30% | Time the main thread was blocked by long tasks during load |
| Cumulative Layout Shift (CLS) | 25% | Total visible layout shift during the page view lifetime |
| First Contentful Paint (FCP) | 10% | When the first text or image painted |
| Speed Index (SI) | 10% | How quickly content visually populated |
- TBT (30%) is the single largest lever; reducing main-thread time during load yields the largest gains.
- LCP and CLS together are 50% of the score: the LCP element and any late-arriving content are the two highest-leverage targets.
- FCP and SI together are only 20%; optimizing them without fixing TBT/LCP/CLS plateaus.
- INP is reported but does not affect the lab Performance score. It does affect real-user CrUX data and Search Console.
### Scored audits versus diagnostic Insights
Insights (network dependency tree, LCP breakdown, critical request chains) are leads, not gates. A long chain or non-zero critical path is worth fixing ONLY if it actually delays the LCP element. If LCP already completes before the chain ends, do nothing.
## Run Lighthouse Properly
### Production build only, never the dev server
A dev server actively MANUFACTURES phantom failures that do not exist in production:
| Dev-server artifact | Audit it falsely trips |
|---------------------|------------------------|
| Dev toolbar/overlay injecting a low-quality link or extra DOM | SEO link-text, extra DOM nodes |
| Hot-reload WebSocket holding the connection open | BFCache fail, `errors-in-console` |
| Unminified, unbundled, source-mapped assets | depressed Performance, misleading `unused-javascript` / `unused-css` |
Score a production build via the framework's own preview server or the live edge, never the dev server. See debug-recipes.md Lighthouse flake triage.
```text
<production build command>
<start production server> --port 3001 &
npx lighthouse "http://localhost:3001/<path>" \
--output=json --output=html \
--output-path=/tmp/lh-mobile \
--chrome-flags="--headless=new --no-sandbox --disable-gpu" \
--only-categories=performance,accessibility,best-practices,seo \
--quiet
```
### Preview is closer than dev, but is still not the edge
Local preview lacks the host response headers (cache, security, compression) and is not the artifact users hit. Before declaring a live score fixed:
- Confirm the deploy landed: the expected asset hash or a content marker is present in the served HTML.
- Measure the deployed URL or read CrUX.
- The user's live/field number outranks any local run.
### Mobile and desktop, independently
```text
# Mobile (default preset: simulated 4G, Moto G Power, 5.6x CPU throttle; 4x CPU + slow 4G profile)
npx lighthouse "http://localhost:3001/<path>" --output=json --output-path=/tmp/lh-mob.json \
--chrome-flags="--headless=new --no-sandbox --disable-gpu" --quiet
# Desktop (preset: native viewport, 0x CPU throttle, 40ms RTT, no throttle)
npx lighthouse "http://localhost:3001/<path>" --output=json --output-path=/tmp/lh-desk.json \
--preset=desktop --chrome-flags="--headless=new --no-sandbox --disable-gpu" --quiet
```
Most regressions show up on mobile first.
### Pin Chrome, take the median
- Local Chrome updates can shift scores by 2-5 points. Pin Chrome for CI (a managed-browsers fetch or a test runner's bundled Chromium) and set `CHROME_PATH` explicitly.
```bash
export CHROME_PATH="$(find ~/Library/Caches/ms-playwright -name 'Google Chrome for Testing' -type f 2>/dev/null | head -1)"
```
- Natural variance is 2-4 points per run. Run at least 3 times and take the median (CI `numberOfRuns: 3` or `5` is standard).
- Pass `--throttling-method=devtools` (or `provided`) only when comparing against real network conditions; default `simulated` is fine for relative regression checks.
## Failing Audit to Fix Map
### Performance audits
| Audit (id) | Symptom and root cause | Fix |
|-----------|------------------------|-----|
| `largest-contentful-paint-element` | LCP element loads late (image) | Add `fetchpriority="high"`, declare `width`/`height`, serve responsive `srcset`, prefer AVIF/WebP, never lazy-load it |
| `largest-contentful-paint-element` | LCP element loads late (text) | Load its font with `font-display: swap` and preload it; pre-render the section server-side |
| `render-blocking-resources` | Synchronous `<link rel="stylesheet">` or head `<script>` blocks first paint | Inline critical CSS (under 14 KB), defer the rest with `<link rel="preload" as="style" onload="this.rel='stylesheet'">` or `media="print" onload="this.media='all'"`; move scripts to `defer` or `async` |
| `unused-css-rules` | > 20 KB unused CSS (global stylesheet, vendor framework CSS) | Run a production CSS purge, split CSS by route, inline only above-the-fold rules |
| `unused-javascript` | > 40 KB unused JS (whole-module imports) | Use named imports; replace heavy libs with native APIs (`Intl.DateTimeFormat` for date formatting, ES built-ins for utility helpers); audit the bundle with a source-map analyzer |
| `total-byte-weight` | Page exceeds ~1.6 MB total | Compress images (AVIF/WebP), code-split, treeshake, drop polyfills for evergreen browsers (`browserslist: ['>0.5%', 'not dead', 'not op_mini all']`) |
| `efficient-animated-content` | Animated unoptimized GIF | Replace with `<video autoplay muted loop playsinline preload="metadata">` serving WebM + MP4, or animated WebP/AVIF |
| `modern-image-formats` | Legacy JPEG/PNG served | Add an AVIF/WebP step; serve via `<picture>` with `<source type="image/avif">`, `<source type="image/webp">`, then `<img>` fallback |
| `uses-optimized-images` | Images larger than necessary, no build-step compression | Run lossy compression (JPEG quality 75-82, lossy PNG, AVIF quality 50-65) and strip metadata |
| `uses-responsive-images` | Image larger than its rendered box, one size for all viewports | Add `srcset` with at least 3 widths (e.g. 480w, 960w, 1440w) and an explicit `sizes`. See the DPR arithmetic below |
| `offscreen-images` | Below-the-fold images load eagerly | Add `loading="lazy"` to all below-the-fold `<img>`; never lazy-load the LCP element |
| `uses-text-compression` | Text assets uncompressed | Enable Brotli at the edge with gzip fallback; verify with `curl -H "Accept-Encoding: br,gzip" -I` |
| `uses-rel-preload` | Critical request discovered late (font, hero, critical script) | Add `<link rel="preload" as="font" type="font/woff2" crossorigin>` or `as="image"` for the LCP image; limit preloads to genuinely critical resources |
| `font-display` | Invisible text during font load (`font-display` defaults to `block`) | Set `font-display: swap` on body fonts and `font-display: optional` on display fonts when LCP risk exists |
| `uses-long-cache-ttl` | Static assets cached < 1 year | Static immutable assets get `Cache-Control: public, max-age=31536000, immutable`; HTML gets `s-maxage=300, stale-while-revalidate=86400` or similar |
| `legacy-javascript` | Polyfills shipped to evergreen browsers | Set `target: 'es2020'` or higher; use module/nomodule only if actively supporting legacy |
| `bootup-time` | JS execution > 2s on mobile | Code-split, defer non-critical hydration (islands, lazy hydration), move heavy work to web workers |
| `mainthread-work-breakdown` | Long tasks > 50ms | Split bundles, defer hydration, batch reads/writes, move to `requestIdleCallback` |
| `dom-size` | DOM > 1500 nodes | Virtualize lists with 50+ items, lazy-render off-screen sections, strip dead containers |
| `third-party-summary` | Third parties take > 250ms main thread | Audit and remove; lazy-load chat via `requestIdleCallback`; first-party proxy for analytics; move third-party JS to a worker |
| `cumulative-layout-shift` | CLS > 0.1 (undimensioned media, late fonts, late-injected banners/ads) | Declare width/height on every `<img>`, `<iframe>`, `<video>`; reserve space with `aspect-ratio`; use `font-display: optional` + `size-adjust` to prevent FOUT shifts; never inject above-the-fold content after first paint |
| `non-composited-animations` | Animating `width`, `height`, `top`, `left`, `margin` | Refactor to `transform` (`translate3d`, `scale`) and `opacity` |
| `uses-passive-event-listeners` | `touchstart`/`wheel` listeners block scroll | Add `{ passive: true }` to scroll-side listeners |
| `no-document-write` | `document.write` detected (legacy third party) | Remove; replace with async DOM manipulation |
#### Responsive-image arithmetic (image-size-responsive and uses-responsive-images)
Both fail when the largest `srcset` candidate is smaller than rendered CSS width times device pixel ratio (DPR). The arithmetic IS the fix:
- largest demand = (max CSS width from `sizes`) times (max DPR, usually 2 or 3).
- A 360px box at 2x needs a 720w candidate.
- For an LCP/hero image, emit up to about 2x the maximum layout width.
### Accessibility audits
Lighthouse runs the axe-core ruleset; failing any rule drops the score below 100 (binary on most rules).
| Audit | Root cause | Fix |
|-------|-----------|-----|
| `color-contrast` | Foreground/background below 4.5:1 (or 3:1 for large text) | Darken/lighten one side; verify the `getComputedStyle` ratio in DevTools; test light AND dark modes independently |
| `image-alt` | `<img>` without `alt` | Add `alt="..."`; decorative images get `alt=""` (empty string, not missing) |
| `label` | Form control without label | Wrap with `<label>` or use `for`/`id`; `aria-label` only when a visible label is genuinely impossible |
| `link-name` | Link with no discernible name | Provide visible text or `aria-label`; icon-only links must have `aria-label` |
| `button-name` | Button with no discernible name | Same as link-name |
| `aria-required-attr` / `aria-valid-attr-value` | Invalid ARIA usage | Validate against the ARIA 1.2 spec; prefer native semantics over ARIA |
| `heading-order` | Skipped heading level | Sequential h1 -> h2 -> h3; never use a heading for styling |
| `html-has-lang` | Missing `<html lang="...">` | Set the document language |
| `html-lang-valid` | Invalid lang code | Use a valid BCP 47 code (`en`, `en-US`, `de-DE`) |
| `meta-viewport` | Missing or zoom-disabled viewport | `<meta name="viewport" content="width=device-width, initial-scale=1">`; never `user-scalable=no` or `maximum-scale=1` |
| `tabindex` | Positive tabindex (`tabindex="3"`) | Natural document order; `tabindex="0"` and `tabindex="-1"` are fine, positive values break tab flow |
| `duplicate-id` | Two elements share the same `id` | Derive a per-instance unique id (a unique-id generator) and namespace child ids under it. See components.md |
| `bypass` | No skip link | Add `<a href="#main" class="sr-only-focusable">Skip to content</a>` as the first focusable element |
| `aria-hidden-focus` | Focusable element inside `aria-hidden` | Do not focus into hidden subtrees; move focus or remove `aria-hidden` |
| `frame-title` | `<iframe>` without `title` | Provide a `title` describing the frame's purpose |
| `list` | `<ul>`/`<ol>` with non-`<li>` children | Restructure markup |
| `td-headers-attr` / `th-has-data-cells` | Data table without header association | Use `<th scope="col">` and `<th scope="row">`; for complex tables use `headers="..."` |
#### CSS delivery can silently flip a contrast pass
A change to HOW CSS is delivered (inlining, bundling, chunk/concat order, a build flag) can reorder the cascade and silently flip a computed color or link style for rules of equal specificity, with zero authored-rule change.
- Check: after ANY CSS-delivery change, re-run axe / the Accessibility audit on the FULL set of routes (not just the changed page) and eyeball both audit viewports.
### Best Practices audits
| Audit | Cause | Fix |
|-------|-------|-----|
| `is-on-https` | Page or sub-resource over HTTP | Force HTTPS at the edge with HSTS; audit mixed content (`https://` page loading `http://` sub-resources) |
| `errors-in-console` | JS errors at load | Fix every error; Lighthouse fails on any console error |
| `image-aspect-ratio` | Distorted aspect ratio | Honor the natural ratio or use `object-fit: cover/contain` |
| `image-size-responsive` | Natural size much smaller than rendered | Serve a higher-resolution source (see DPR arithmetic above) |
| `notification-on-start` | Notification permission requested without a user gesture | Move to a user-initiated trigger |
| `geolocation-on-start` | Geolocation requested without a user gesture | Move to a user-initiated trigger |
| `paste-preventing-inputs` | `onpaste="return false"` | Remove it; users must be able to paste |
| `inspector-issues` | DevTools-flagged issues | Open the Issues panel; fix each |
| `csp-xss` | Missing or weak CSP | Add a Content-Security-Policy: per-request nonces for inline scripts on a dynamic server, build-time script hashes on static hosting; treat `'unsafe-inline'` for `script-src` as an explicit fallback only; keep `object-src`, `base-uri`, `frame-ancestors` strict (see below) |
| `csp-trusted-types` | CSP set but Trusted Types not enforced | Add `require-trusted-types-for 'script'` and `trusted-types <policy-name>`; this blocks DOM-XSS sinks (`innerHTML`, `outerHTML`, `document.write`, script `src`) unless the value is a `TrustedHTML`/`TrustedScript`/`TrustedScriptURL` from a named policy. Route raw sink calls through one sanitizing policy. See security.md |
| `sri` | Third-party script without integrity | For any third-party `<script src>` or `<link rel="stylesheet" href>` you cannot self-host, add `integrity="sha384-<hash>" crossorigin="anonymous"` and pin to a specific versioned URL (SRI does not work against `latest` tags). See the beacon note below |
| `coop-coep-corp` | Cross-origin isolation headers missing | Set `Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp`, `Cross-Origin-Resource-Policy: same-origin` on first-party responses (required for `SharedArrayBuffer`, high-precision `performance.now()`, `OffscreenCanvas` in a worker); every cross-origin sub-resource must opt in via `Cross-Origin-Resource-Policy: cross-origin` or CORP-equivalent CORS |
| `permissions-policy` | Powerful APIs available to every origin | Gate `geolocation`, `camera`, `microphone`, `payment`, `usb`, `accelerometer`, `gyroscope`, `magnetometer` via the `Permissions-Policy` header; default to deny (`Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=(), usb=()`) and opt in only origins that need it (`geolocation=(self "https://maps.your-domain")`); same rule for `<iframe allow="...">` |
| `valid-source-maps` | Source maps not served or invalid | Ship production source maps to your error tracker via its private upload step; never publish `.map` files alongside the JS on the public CDN. Either upload at build time (no public reference) or restrict the `SourceMap:` header / `//# sourceMappingURL` comment to error-tracker IP ranges via an edge rule. The public response stays unsourced |
| `no-unload-listeners` | `unload` event listener | Replace with `pagehide` or `visibilitychange` |
| `deprecations` | Deprecated API used | Replace per the deprecation message |
#### errors-in-console from a third-party beacon: two opposite causes
| Cause | Mechanism | Reality |
|-------|-----------|---------|
| Client false positive | An ad/privacy/DNS blocker in the audit profile blocks it: `net::ERR_BLOCKED_BY_CLIENT` | Production is fine |
| Real ships-to-everyone bug | An edge platform auto-injects the third-party tag with a pinned SRI `integrity` hash + `crossorigin`; SRI on a cross-origin script forces CORS-mode fetch. When the vendor rolls the asset forward, the pinned hash/version goes stale and the load fails with `net::ERR_FAILED` / no `Access-Control-Allow-Origin`, tripping `errors-in-console` for every visitor | Breaks for all |
- Discriminator: reproduce in a clean headless run. If it vanishes, it was the client. If it reproduces every run, inspect the rendered DOM for an `integrity` + `crossorigin` pair on an auto-injected third-party script. A direct fetch of the asset (`curl -I`) still returns a valid `Access-Control-Allow-Origin`, so a header-only check passes and misses it: the failure is the stale pinned hash under CORS-mode page load, not a missing CDN header.
- Fix the real one by self-injecting the plain vendor tag (no `integrity`, no `crossorigin`, no pinned version) so it loads no-cors. Most edge auto-injectors skip injection when the tag is already present, so the plain self-injected tag pre-empts the broken pinned one.
#### CSP and framework hydration
A strict `script-src 'self'` (no nonce, no inline) is the single most common way to silently break a modern site. Island, partial, and resumable hydration emit small inline bootstrap scripts; counters, facades, and disclosure widgets do too. Block them and two things fail at once: the components never hydrate (the page looks fine but nothing is interactive), and `errors-in-console` fails on the CSP violation logs, so you cannot reach 100.
- Dynamic server: mint a per-request nonce; add it to every inline script tag and to `script-src 'nonce-...'`.
- Static hosting (no per-request server): prefer build-time script HASHES. Compute a `sha256` (or `sha384`/`sha512`) hash of each enumerable inline script and list them in `script-src 'sha256-...'`; hashes are strictly safer than `'unsafe-inline'` and work on a static host. Fall back to `'unsafe-inline'` only when the hash set is impractical to enumerate. Either way keep everything else strict: `object-src 'none'`, `base-uri 'self'`, `frame-ancestors 'none'`, and scope `frame-src` and `img-src` to the embeds you actually use.
- Verify in the SHIPPED headers, not the source config. A headers file or rule can be transformed or dropped by the build; check the built output and the live response (`curl -I`).
### SEO audits
| Audit | Cause | Fix |
|-------|-------|-----|
| `document-title` | Missing `<title>` | Add a unique, intent-matching title of 50-60 chars |
| `meta-description` | Missing `<meta name="description">` | Add a unique 140-160 char description with the primary intent term and a value proposition |
| `http-status-code` | Non-200 status | Fix the route |
| `link-text` | Generic link text ("click here", "read more") | Replace with descriptive anchor text |
| `crawlable-anchors` | Anchors with `javascript:void(0)` or no `href` | Use real `href` values; SPA link components should still emit href |
| `is-crawlable` | `noindex` or robots block | Remove the block on indexable pages |
| `robots-txt` | robots.txt malformed or unreachable | Validate; ensure it returns 200 and references the sitemap |
| `image-alt` | Missing alt | See Accessibility |
| `hreflang` | Invalid hreflang | Use valid codes; ensure mutual hreflang on all locale variants |
| `canonical` | Missing or incorrect canonical | Self-referencing canonical on the indexable URL |
| `structured-data` | (manual) | Validate via a rich-results test; add JSON-LD per the schema.org type that matches the page |
## Lighthouse User Flow Audits
The default `npx lighthouse <url>` scores only a cold page load (a "navigation" audit) and cannot meaningfully score INP, which needs a real interaction during the page lifetime. Two extra modes via the Flow API:
- Timespan: records every Web Vitals metric during an arbitrary window while the user (or script) interacts. The only lab way to get a meaningful INP number. Use it on the slowest interaction (search submit, filter apply, modal open) and read INP from the timespan report.
- Snapshot: scores the page in its current rendered state. Use it inside a multi-step flow to score a modal, sheet, or post-interaction state the navigation audit cannot reach.
Run from a headless browser of your choice (Puppeteer, Playwright, or equivalent):
```js
import { startFlow } from 'lighthouse';
import { launch } from '<headless browser of your choice>';
const browser = await launch({ headless: 'new' });
const page = await browser.newPage();
const flow = await startFlow(page, { name: 'Search flow' });
await flow.navigate('https://your-domain/');
await flow.startTimespan({ name: 'Search interaction' });
await page.click('[data-search-input]');
await page.type('[data-search-input]', 'frontend excellence');
await page.keyboard.press('Enter');
await page.waitForSelector('[data-search-result]');
await flow.endTimespan();
await flow.snapshot({ name: 'Search results visible' });
const report = await flow.generateReport();
await browser.close();
```
Capture during the real interaction against the real DOM, not a synthetic mouse event in `<head>`; the long-task budget only shows up if the handler runs against the real DOM.
## Setting Up CI Gates
A minimal config asserts category scores and metric ceilings, runs each URL `numberOfRuns: 3`, desktop preset:
```json
{
"ci": {
"collect": {
"startServerCommand": "npm run start -- -p 3001",
"url": [
"http://localhost:3001/",
"http://localhost:3001/<key-route-1>",
"http://localhost:3001/<key-route-2>"
],
"numberOfRuns": 3,
"settings": { "preset": "desktop" }
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.99 }],
"categories:accessibility": ["error", { "minScore": 1.00 }],
"categories:best-practices": ["error", { "minScore": 1.00 }],
"categories:seo": ["error", { "minScore": 1.00 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2000 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.05 }],
"total-blocking-time": ["error", { "maxNumericValue": 100 }]
}
},
"upload": { "target": "temporary-public-storage" }
}
}
```
Two URL-list rules:
- Cover every indexable route CLASS, not a subset. One representative per template (home, each hub, a detail page of each kind, each standalone page). A route class never scored can regress and still pass CI; the common miss is a new content-page template never added to the list.
- Never put a `noindex` page in a config asserting `"categories:seo": ["error", { "minScore": 1 }]`. The `is-crawlable` audit fails on `noindex`, dropping SEO below 1 and failing the gate even though the page is intentionally non-indexable. Score noindex pages (a 404, a duplicate, an auth wall) in a separate config without the SEO assertion, or leave them out.
Run a separate mobile config with `preset: 'mobile'` (default) and a lower 0.95 perf threshold. Run both mobile and desktop as required CI gates.
### CI wiring
A pre-built CI action runs on every PR, uploads HTML reports as artifacts, and posts score deltas back to the PR:
```yaml
name: Lighthouse CI
on: [pull_request]
jobs:
lhci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci && npm run build
- uses: <lighthouse-ci-action>@v12
with:
configPath: ./lighthouserc.json
uploadArtifacts: true
temporaryPublicStorage: true
```
- `uploadArtifacts: true` attaches every HTML report so reviewers can open the full waterfall on a failure.
- `temporaryPublicStorage: true` pushes the report to public storage and prints the URL in the log; the action also adds a status check per assertion, so the failing audit is visible without opening the report.
- For a self-hosted history server (retention, trend graphs, alerts), point `upload.target` at `lhci-server` and set `LHCI_TOKEN` / `LHCI_SERVER_BASE_URL` as secrets.
Local equivalent (reproduce CI failures before pushing):
```bash
lhci collect # runs Lighthouse N times against the URL list
lhci assert # exits non-zero if any assertion fails
lhci upload # pushes the reports to your chosen target
```
## Diagnosing Score Drops
When Lighthouse drops 5+ points between runs (a 2-3 point swing is noise; 5+ is a regression):
1. Open the new and old reports side by side; compare each metric numerically; identify the metric that moved.
2. LCP: did the LCP element id change, or the asset get bigger?
3. TBT: in the Performance trace long-tasks panel, identify the new long task by its source URL.
4. CLS: in the Layout Instability filmstrip, identify the shifting element.
5. FCP: check whether new render-blocking resources were added.
Fix scored-audit failures and ignore an Insight when the scored metric is already green.
## Field vs Lab
| Source | What it is | Use |
|--------|-----------|-----|
| Lighthouse (lab) | Controlled lab run | Catch regressions before deploy |
| CrUX (field) | Real-user 28-day p75 | What Search Console uses for ranking signals |
| `web-vitals` (field) | Real-time field telemetry to your own pipeline | Production instrumentation, especially INP |
Pass the lab AND the field. INP shows up in the field but not the lab; instrument it in production.
INP-in-CI gate via CrUX: lab Lighthouse cannot score INP meaningfully. Query the CrUX API (or PageSpeed Insights API) for the origin's 28-day p75 INP and fail the build if it exceeds 200ms. Run on a daily schedule, not per PR (CrUX updates lag deploys):
```bash
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
-H 'Content-Type: application/json' \
-d '{"origin":"https://your-domain","metrics":["interaction_to_next_paint"]}' \
| jq -e '.record.metrics.interaction_to_next_paint.percentiles.p75 <= 200'
```
Pair with the Flow API timespan audit (above) for interaction-level lab gates on PRs.
## Common Misconceptions
- "Desktop is always 100." False. Heavy hydration, oversized images, and broken meta still tank desktop.
- "We can fix it later." Performance debt compounds; add a budget guard now.
- "It is only a public-facing surface." Public surfaces are the entry point for organic search and directly affect CrUX and rankings.
- "We minified, we are fine." Minification reduces bytes by ~30%; the bigger lever is what you ship at all (treeshake, code-split, defer hydration).
- "We have a CDN, that is enough." A CDN moves bytes faster; it does not parse, hydrate, or render faster. The client work is the bottleneck.
## See Also
- [performance.md](performance.md) for the deep dive on each performance lever
- [accessibility.md](accessibility.md) for the deep dive on accessibility audits
- [seo.md](seo.md) for the deep dive on SEO audits
- [debug-recipes.md](debug-recipes.md) for Lighthouse flake triage and CLS/INP diagnosis
references/motion.md
---
title: Motion and Animation
purpose: Framework-agnostic motion that conveys meaning, holds 60fps, respects user preferences, and uses modern platform primitives (View Transitions, WAAPI, scroll-driven, @starting-style). Every above-the-fold animation ships only inside the LCP/TBT/CLS budget.
load-when:
task-keywords: [motion, animation, transition, easing, View Transitions, scroll-driven, WAAPI, will-change, "@starting-style", reduced motion, INP, CLS]
symptoms: [INP regression, slow interaction, CLS regression, score dropped]
prereq: SKILL.md
related: [performance.md, accessibility.md, ui-ux.md, design.md]
size: ~688 lines
---
# Motion and Animation
Motion is a tool, not decoration. It earns its place only by doing one of four jobs; otherwise remove it.
## Why Motion
- Communicate cause and effect: a button press triggers a state change and the motion confirms it.
- Maintain spatial continuity: show where an element came from and where it goes.
- Direct attention: animate what the user should look at next.
- Express brand personality: spring bounces feel playful, linear fades feel refined.
- If a motion serves none of cause/effect, spatial continuity, attention, or brand: remove it.
## The Four Properties of UI Motion
| Property | Default | Range / Rule |
|----------|---------|--------------|
| Duration | 200-250ms (UI), 300-400ms (modal/page transition) | 100-500ms; rarely above 500ms in UI |
| Easing | `ease-out` entering, `ease-in` exiting | `linear` forbidden in UI |
| Trigger | User action or state change | Time alone (idle) is decorative |
| Interruptibility | Always interruptible | Never block input |
## Duration
| Interaction | Duration |
|-------------|----------|
| Hover, focus, press feedback | 100-150ms |
| Toggle, switch | 150-200ms |
| Disclosure, accordion | 200-300ms |
| Tab change, segmented control | 150-250ms |
| Modal/sheet entrance | 250-350ms |
| Modal/sheet exit | 200-250ms (faster than entrance) |
| Page/route transition | 300-400ms |
| Onboarding sequence | 400-600ms per beat |
| Skeleton shimmer | 1500ms loop, low contrast |
- Above 500ms feels slow in UI: reserve for one-off cinematic moments.
- Exit motions should be 60-70% of entrance duration so getting content out of the way feels responsive.
## Easing
| Curve | Use |
|-------|-----|
| `ease-out` (`cubic-bezier(0, 0, 0.2, 1)`) | Entering: element decelerates as it arrives. |
| `ease-in` (`cubic-bezier(0.4, 0, 1, 1)`) | Exiting: element accelerates as it leaves. |
| `ease-in-out` (`cubic-bezier(0.4, 0, 0.2, 1)`) | Symmetric state change in place. |
| `ease` (`cubic-bezier(0.25, 0.1, 0.25, 1)`) | Default; usable but generic. |
| Spring | Playful or characterful: use a physics-based library (stiffness, damping, mass). |
| `linear` | NEVER for UI motion. Reserved for continuous loops (spinners) and progress bars. |
Material Design 3 standard curves (usable defaults):
- Standard: `cubic-bezier(0.2, 0, 0, 1)`
- Decelerated (entering): `cubic-bezier(0, 0, 0, 1)`
- Accelerated (exiting): `cubic-bezier(0.3, 0, 1, 1)`
- Emphasized: `cubic-bezier(0.2, 0, 0, 1)` (longer durations, more dramatic)
Apple-style spring curves (CSS):
- Soft snap: `cubic-bezier(0.34, 1.56, 0.64, 1)` (slight overshoot, soft return)
- Quick snap: `cubic-bezier(0.4, 1.5, 0.5, 1)` (quicker overshoot)
- Reduced motion: `cubic-bezier(0.4, 0, 0.2, 1)` (no overshoot)
For genuinely physics-based motion, use a library with stiffness, damping, mass; that natural feel is one bezier curves rarely match.
### CSS `linear()` for spring approximations
`linear()` (Baseline 2024) takes a list of stops and interpolates linearly between them, approximating a spring or any arbitrary curve in pure CSS, no library and no JS physics simulation.
```css
.bounce {
transition: transform 600ms linear(
0, 0.009, 0.035, 0.078, 0.137, 0.21, 0.296, 0.394, 0.502,
0.62, 0.745, 0.876, 1, 1.062, 1.114, 1.156, 1.186, 1.207,
1.217, 1.219, 1.213, 1.2, 1.18, 1.156, 1.13, 1.103, 1.078,
1.056, 1.039, 1.024, 1.014, 1.007, 1.003, 1.001, 1
);
}
```
Do not hand-write the stops. Generate them once from a spring (stiffness, damping, mass) with a `linear()` generator tool, then paste into CSS. The result runs on the compositor with no main-thread cost and respects `prefers-reduced-motion` through the normal CSS path. Use it in place of hand-rolled cubic-bezier-fit-to-spring and JS spring libraries for the common snap-with-overshoot feel.
## What to Animate (and What NOT to Animate)
Animate (compositor-only, run on the GPU, hold 60fps, no layout/paint):
- `transform` (translate, rotate, scale)
- `opacity`
- `filter` (sparingly)
- `clip-path` (modern browsers)
NEVER animate:
- `width`, `height`, `top`, `left`, `right`, `bottom`, `margin`, `padding`, `font-size`: they trigger layout, then paint, then composite.
- `box-shadow` size (only its `opacity` is cheap).
- Anything that changes layout for surrounding elements.
For expanding/collapsing, use `transform: scale()` plus `clip-path`, or the `view-transition` API for layout transitions.
### When you must animate layout: FLIP
1. Measure the element's First position.
2. Apply the change.
3. Measure the Last position.
4. Compute the Invert transform.
5. Animate transform from inverted to identity (Play).
For full route or DOM transitions, the `view-transition` API is the right tool.
## Web Animations API
CSS animations are the default for declarative, repeatable motion. Reach for the Web Animations API (`element.animate()`, `Animation`, `getAnimations()`) the moment motion becomes stateful, interruptible, dynamic, or coordinated across elements at runtime.
```js
const anim = card.animate(
[{ transform: 'translateY(20px)', opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }],
{ duration: 400, easing: 'cubic-bezier(0.2, 0, 0, 1)', fill: 'forwards' }
);
anim.onfinish = () => anim.commitStyles();
```
What WAAPI does that CSS cannot:
- Interruptible: `anim.pause()`, `anim.cancel()`, `anim.reverse()`, `anim.playbackRate = 0.5`. CSS cannot interrupt cleanly.
- Coordinated: `getAnimations()` returns every running animation on the page, to pause all motion on a route change or gate an is-animating check.
- Stateful: `commitStyles()` writes current values to inline style, so the element ends in its end state without `fill: forwards` quirks.
- Dynamic keyframes: accepts arbitrary computed keyframe objects with runtime DOM-measured values, which CSS cannot.
Reach for WAAPI when:
- You start an animation then interrupt it on a user gesture (cancel on click, reverse on hover-out, pause during a drag).
- You run a FLIP layout animation whose keyframes depend on measured before/after positions.
- You coordinate animations across multiple elements with shared timing or a single global controller.
Stay with CSS animations for hover effects, static reveals, skeleton loops, simple modal entrances: anything where the same animation always plays the same way.
## Reduced Motion
Always respect `prefers-reduced-motion: reduce`. The global stomp is the floor:
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
```
Better: tier the motion.
- Essential (loading spinner conveying activity, progress bar showing percent): keep.
- Helpful (slide-in modal, fade-in card): replace with instant transitions.
- Decorative (parallax, scroll-triggered reveals, hero text staggered animations): remove entirely.
```css
@media (prefers-reduced-motion: reduce) {
.hero-letters span {
opacity: 1;
transform: none;
animation: none;
}
}
```
For JS animations, check the preference first:
```js
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReduced) {
// Run the animation
}
```
See accessibility.md for full reduced-motion treatment.
## Choreography
When multiple elements animate, choreograph them; do not let everything animate at once.
### Stagger
- Stagger list/grid entries by 30-50ms so the user sees a sequence, not a flash.
- Cap stagger at 8-10 items so the last item is not obviously waiting.
```css
.list > * { animation: rise 400ms ease-out backwards; }
.list > *:nth-child(1) { animation-delay: 0ms; }
.list > *:nth-child(2) { animation-delay: 50ms; }
.list > *:nth-child(3) { animation-delay: 100ms; }
```
```js
items.forEach((item, i) => {
item.style.animationDelay = `${i * 50}ms`;
});
```
### Sequence
Hero load: heading first, then sub-heading, then CTAs, then image. Each beat 100-200ms after the previous start.
### Coordinated entry/exit
Modal: backdrop fades in (200ms), then dialog scales up + fades in (250ms). On exit: dialog scales down + fades out (180ms), then backdrop fades out (150ms).
## Scroll-Triggered Motion
### CSS-only (preferred)
```css
.fade-in-on-scroll {
opacity: 0;
transform: translateY(20px);
animation: rise 600ms ease-out forwards;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
@keyframes rise {
to { opacity: 1; transform: translateY(0); }
}
```
Use `IntersectionObserver` as a fallback to cover most cases.
### `scroll()` vs `view()` (both Baseline 2024)
- `view()` ties an animation to an element entering/leaving the viewport (is-this-element-visible).
- `scroll()` ties an animation to a scroll container's position, 0% (top) to 100% (bottom) (where-in-the-document-am-I).
- Both run on the compositor at 60fps and respect `prefers-reduced-motion`.
Canonical `scroll()` uses:
- Reading progress bar, zero JS:
```css
.progress {
position: fixed; top: 0; left: 0; height: 3px;
background: var(--brand);
transform-origin: left;
animation: fill linear;
animation-timeline: scroll(root);
animation-range: 0% 100%;
}
@keyframes fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
```
- Sticky-fade header: background opacity rises from 0 to 1 over the first 100 to 200px of scroll, no scroll listener.
- Section-position indicator: a side-nav dot moves down a vertical track as the page scrolls.
### Intersection Observer (universal)
```js
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('in-view');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.2 });
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));
```
```css
.reveal {
opacity: 0;
transform: translateY(20px);
transition: opacity 600ms ease-out, transform 600ms ease-out;
}
.reveal.in-view {
opacity: 1;
transform: translateY(0);
}
```
### Anti-patterns
- Every section animating on scroll: the page feels like it is still loading at the bottom.
- Parallax on every section: disorienting and often hurts CLS.
- Long animations triggered by scroll: they fire after the user has moved past the trigger.
- Fix: animate selectively, one or two key sections per page.
## Page Transitions
SPA route change:
```css
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 200ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-old(root) { animation-name: fade-out; }
::view-transition-new(root) { animation-name: fade-in; }
```
Shared element transition: matching `view-transition-name` on both elements makes the browser auto-morph between them.
```css
.card-image { view-transition-name: card-image; }
.detail-image { view-transition-name: card-image; }
```
### Cross-document View Transitions (MPA)
Ships in Chrome 126+ and Safari 18+; Firefox is rolling it out, so treat it as progressive enhancement. One line opts the whole site into cross-page transitions, no JS, router, or framework:
```css
@view-transition {
navigation: auto;
}
```
The browser captures the outgoing document, fetches and parses the incoming one, then crossfades as the new page renders. Shared elements across the boundary auto-morph by matching name:
```css
.product-image { view-transition-name: product-hero; }
```
Constraints:
- Same-origin only: the browser will not transition across origins.
- Both navigations must opt in (`@view-transition { navigation: auto }` in the CSS of both pages).
- Firefox without support degrades to a normal navigation, no transition; the page still works.
## Loading and Progress Indicators
### Spinner
For < 1s waits: linear rotation, infinite, 800-1000ms per revolution. Add `aria-label="Loading"` (or wrap in a container with one).
```css
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { animation: spin 800ms linear infinite; }
```
### Skeleton screen
For 300ms+ waits: match the eventual layout, animate a subtle shimmer. Mark with `aria-busy="true"` and `aria-live="polite"` so screen readers announce when content arrives.
```css
.skeleton {
background: linear-gradient(
90deg,
var(--surface-muted) 0%,
var(--surface) 50%,
var(--surface-muted) 100%
);
background-size: 200% 100%;
animation: shimmer 1500ms ease-in-out infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
```
### Progress bar
For determinate progress, animate `transform: scaleX()` from 0 to 1, not `width`.
```css
.progress-bar {
transform-origin: left;
transform: scaleX(0);
transition: transform 200ms linear;
}
.progress-bar.at-50 { transform: scaleX(0.5); }
```
## Hover and Press Effects
- Button hover: shift background by one tone, change border, raise by 1-2px.
- Card hover: lift slightly, subtle shadow change.
- Press scale: subtle 0.95-0.98 scale on press, restore on release.
- Mobile: rely on `:active`; the browser handles touch feedback.
```css
.btn {
transition: transform 150ms ease-out, background 150ms ease-out;
}
.btn:hover { transform: translateY(-1px); background: var(--primary-hover); }
.btn:active { transform: translateY(0) scale(0.98); transition-duration: 50ms; }
.card {
transition: transform 200ms ease-out, box-shadow 200ms ease-out;
}
.card:hover { transform: translateY(-2px); box-shadow: var(--shadow-elevated); }
```
## Modal / Sheet Entrance
### Open-from-closed (`@starting-style`)
Animating an element toggling `display: none` to `display: block` (the natural state for popovers, dialogs, toasts) is now native via three features together, the canonical way to animate open from closed:
- `@starting-style { ... }` declares the styles the element takes the moment it enters the DOM (or leaves `display: none`); the browser interpolates from there to the active styles.
- `transition-behavior: allow-discrete` lets the browser animate discrete properties (including `display`) over the duration instead of snapping at the boundaries.
- `transition: display ...` combined with the two above makes `display: none` to `display: block` actually play.
```css
[popover] {
opacity: 0;
transform: scale(0.96);
transition:
opacity 200ms ease-out,
transform 200ms ease-out,
display 200ms allow-discrete,
overlay 200ms allow-discrete;
}
[popover]:popover-open {
opacity: 1;
transform: scale(1);
}
@starting-style {
[popover]:popover-open {
opacity: 0;
transform: scale(0.96);
}
}
```
Baseline-modern (Chrome, Safari, Edge; Firefox shipping). Use for popovers, dialogs, dropdowns, toasts, tooltips. Fallback without support is an instant snap that degrades gracefully.
### Modal (centered)
Order: backdrop fades in, then modal fades + scales in 50ms after.
```css
.modal {
opacity: 0;
transform: scale(0.95);
transition: opacity 200ms ease-out, transform 200ms ease-out;
}
.modal.open { opacity: 1; transform: scale(1); }
.modal-backdrop { opacity: 0; transition: opacity 200ms ease-out; }
.modal-backdrop.open { opacity: 1; }
```
### Sheet (slides from edge)
`translateY(100%)` puts it just below the visible area; the cubic-bezier approximates the iOS bottom-sheet overshoot.
```css
.sheet-bottom {
transform: translateY(100%);
transition: transform 250ms cubic-bezier(0.32, 0.72, 0, 1);
}
.sheet-bottom.open { transform: translateY(0); }
```
### Dropdown / popover
```css
.popover {
opacity: 0;
transform: translateY(-4px) scale(0.97);
transform-origin: top;
transition: opacity 150ms ease-out, transform 150ms ease-out;
pointer-events: none;
}
.popover.open {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
```
## Toast / Snackbar
Slide in from edge + fade. Auto-dismiss after 3-5s.
```css
.toast {
transform: translateY(100%);
opacity: 0;
transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1), opacity 200ms ease-out;
}
.toast.show { transform: translateY(0); opacity: 1; }
.toast.hide { transform: translateY(100%); opacity: 0; }
```
## Hero Animations
### PRE-MERGE GATE (hero / above-the-fold)
Any above-the-fold or hero animation ships ONLY with a Lighthouse run on the LCP page showing LCP, TBT, and CLS still inside budget. Visual and accessibility sign-off is NOT performance sign-off. If it cannot hold the budget, make it static or cut it. The default suspect is a per-frame JS-driven hero animation (writing attributes/styles each frame): prefer a CSS keyframe / compositor animation, or static.
### Staggered text rise
Cap at 8-10 items; beyond that the animation feels slow.
```css
.hero-words span {
display: inline-block;
opacity: 0;
transform: translateY(0.5em);
animation: rise 600ms cubic-bezier(0.2, 0, 0, 1) forwards;
}
.hero-words span:nth-child(1) { animation-delay: 100ms; }
.hero-words span:nth-child(2) { animation-delay: 180ms; }
.hero-words span:nth-child(3) { animation-delay: 260ms; }
@keyframes rise {
to { opacity: 1; transform: translateY(0); }
}
```
### Image reveal
```css
.hero-image {
clip-path: inset(0 100% 0 0);
animation: reveal 800ms cubic-bezier(0.6, 0, 0, 1) 200ms forwards;
}
@keyframes reveal {
to { clip-path: inset(0 0 0 0); }
}
```
### Subtle parallax on hero
The hero image drifts slowly, capped to a few pixels. Throttle the scroll handler to 16ms (one frame).
```css
.parallax-hero {
transform: translateY(0);
transition: transform 100ms linear;
}
```
```js
window.addEventListener('scroll', () => {
const offset = Math.min(window.scrollY * 0.3, 100);
document.querySelector('.parallax-hero').style.transform = `translateY(${offset}px)`;
});
```
Anti-pattern: full-page parallax on every section is disorienting and CLS-risky.
## Continuous / Ambient Motion
Subtle ongoing motion gives a page life. Use sparingly.
```css
.ambient-bg {
background: linear-gradient(120deg, #color1, #color2, #color3);
background-size: 200% 200%;
animation: shift 30s ease-in-out infinite;
}
@keyframes shift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.floating { animation: float 4s ease-in-out infinite; }
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
```
A floating decoration drifts up/down 4-8px continuously. Disable continuous motion when:
- The page is not in the active tab (`document.visibilitychange`), to save battery.
- The user prefers reduced motion.
- The element is off-screen.
```js
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
entry.target.style.animationPlayState = entry.isIntersecting ? 'running' : 'paused';
});
});
```
## Motion Budget and Measurement
Motion is a budget like JS, CSS, and images: main-thread work per animation and the total sustained fps. Measure, do not guess.
### PRE-MERGE GATE (above-the-fold / hero)
Same gate as Hero Animations, restated as the budget rule: any above-the-fold or hero animation ships ONLY with a Lighthouse run on the LCP page showing LCP, TBT, and CLS still inside budget. Visual and accessibility sign-off is NOT performance sign-off. If it cannot hold the budget, make it static or cut it. A per-frame JS-driven hero animation (writing attributes/styles each frame) is the default suspect; prefer a CSS keyframe / compositor animation, or static.
### Audit, threshold, and fix
| Tool / metric | What it shows | Fix |
|---------------|---------------|-----|
| DevTools Performance > Animations panel | Every running animation; anything not compositor-only (transform, opacity, filter) shows up in red | Move to composited properties |
| Performance panel: Long Animation Frames (LoAF), tasks > 50ms | Locate the animation's stack frames | Cut the long task in that frame |
| Frame budget 16.7ms (60fps) | Any frame past that drops below 60fps; two consecutive long frames are perceptible | Shorten or composite the animation |
| CPU throttle 4x or 6x in Performance settings | Reproduces jank on a fast machine | Record, inspect red long frames, move to a composited property or off-main-thread, re-record until green |
| Target | Every motion holds 60fps on a 4x throttled CPU profile, which means it holds on the median real device | n/a |
### `will-change` hygiene
`will-change` promotes an element to its own compositor layer ahead of an animation, removing the layer-creation pause when it starts. Misused, it inflates GPU memory and slows the page.
- Declare `will-change` ONLY immediately before the animation starts (on hover-intent, the pointer-down beginning a drag, the keypress opening a panel) via class or `style.willChange`.
- Remove it as soon as the animation ends (on `animationend`, `transitionend`, or the WAAPI `Animation.finished` resolve); leaving it on consumes memory and degrades other animations.
- Never apply `will-change: transform` globally to large element classes (e.g., every card); the browser allocates a separate layer for each.
- Animating `transform` and `opacity` (composited) rarely needs `will-change` at all. If a property triggers layout, `will-change` does not save you; rewrite the animation.
## INP and Animation
Composited-only animations (transform, opacity) don't impact INP; layout-triggering and JS-driven animations can. If INP regresses after adding animation:
| Cause | Fix |
|-------|-----|
| Animation triggers layout | Move to transform/opacity |
| JS animation runs every frame | Use CSS animations or `web-animations-api` |
| Animation runs during page load | Defer until after `load` |
See performance.md for INP attribution and budgets.
## CLS and Animation
- Animations that change element size cause layout shift: avoid them.
- If you must animate size, use `transform: scale()` (compositor-only) and accept it is a visual approximation.
- For accordion/disclosure, pre-measure content height and animate `max-height` to that exact value, or use `interpolate-size: allow-keywords` plus `transition: height` to animate to `auto`.
See performance.md for full CLS treatment.
## Common Motion Mistakes
- Using `linear` for UI transitions.
- Animating `width`, `height`, `top`, `left`.
- 800ms+ durations on common UI.
- Animations that block input (modal opening can't be cancelled mid-animation).
- Different durations for opening and closing the same component.
- Decorative animation on every section.
- Parallax that causes CLS.
- Hover-only effects with no touch equivalent.
- `prefers-reduced-motion` ignored.
- Animating during page load on the LCP element.
- Continuous animation on a hero, draining battery.
- Spinner with no `aria-label`.
- Skeleton without `aria-busy`.
## Self-Healing for Motion
Before declaring work complete:
- [ ] All animations use `transform` and/or `opacity` (no layout-triggering)
- [ ] Durations 100-500ms with intentional choice per interaction
- [ ] Easing chosen per direction (`ease-out` for in, `ease-in` for out)
- [ ] Exit faster than entrance (60-70%)
- [ ] `prefers-reduced-motion` removes or shortens non-essential motion
- [ ] No animation blocks user input (modals are interruptible)
- [ ] Loading states have skeleton at 300ms, spinner before, progress for determinate
- [ ] Spinner / skeleton have accessible names / `aria-busy`
- [ ] Stagger capped at 8-10 items
- [ ] Continuous motion paused when off-screen and tab inactive
- [ ] No animation regresses LCP, INP, or CLS
- [ ] PRE-MERGE GATE: every above-the-fold / hero animation has a Lighthouse run on the LCP page showing LCP, TBT, and CLS inside budget; per-frame JS-driven hero animations replaced with CSS keyframe / compositor or made static; if budget cannot hold, motion is cut
## See Also
- [performance.md](performance.md) for animation cost, INP attribution, and CLS budgets
- [accessibility.md](accessibility.md) for reduced motion
- [ui-ux.md](ui-ux.md) for state transitions
- [design.md](design.md) for motion-driven brand expression
references/embed-patterns.md
---
title: Embed Patterns (Host and Guest)
purpose: Embed-as-host and embed-as-guest rules for iframes: sandboxing, allow-lists, viewport coupling, theme handshakes, viewport reporting, top-window navigation, origin verification, and cross-origin isolation impact.
load-when:
task-keywords: [embed, iframe, sandbox, postMessage, host, guest, third-party widget, frame-ancestors, Permissions-Policy, cross-origin isolation, COOP, Storage Access API]
symptoms: [third-party script slow, viewport overflow, broken on Safari, broken on Firefox]
prereq: SKILL.md
related: [security.md, auth.md, performance.md, accessibility.md]
size: ~326 lines
---
# Embed Patterns (Host and Guest)
Every site plays both roles: it embeds third-party widgets (maps, video, payment, support chat, dashboards) AND its own widgets get embedded elsewhere. Roles have different obligations.
- HOST embeds someone else's origin: bound the guest's capabilities (sandbox, allow-list, storage isolation, validate inbound messages).
- GUEST runs inside someone else's iframe: report viewport, adopt theme, verify origin on every inbound message, degrade when storage is partitioned.
Confusing the roles ships XSS surface (host trusting guest) or a widget that only works first-party (guest assuming first-party storage).
## Embed as Host: You Embed Third-Party
You render an iframe pointing at code you cannot audit at request time. Bound its capabilities.
### Sandbox flags
The `sandbox` attribute is opt-in trust. With no value the iframe has zero capabilities: no scripts, no forms, no top-level navigation, no same-origin treatment of its own origin. Add capabilities only as the embedded content requires them.
| Flag | Grants |
|---|---|
| `allow-scripts` | The iframe may run JavaScript. |
| `allow-same-origin` | The iframe may treat its own origin as same-origin (cookies, storage). |
| `allow-forms` | The iframe may submit forms. |
| `allow-popups` | The iframe may open new windows. |
| `allow-popups-to-escape-sandbox` | Windows opened by the iframe are not themselves sandboxed. |
| `allow-top-navigation` | The iframe may navigate the top window. |
| `allow-top-navigation-by-user-activation` | Top navigation only inside a user gesture. |
| `allow-modals` | The iframe may show `alert`, `confirm`, `prompt`. |
| `allow-storage-access-by-user-activation` | The iframe may call `requestStorageAccess`. |
### Minimum-trust set and untrusted content
- Minimum-trust set: for a typical script-driven third-party widget, use `<iframe src="https://widget.example/partner" sandbox="allow-scripts allow-same-origin" loading="lazy" title="Partner widget">`. Scripts and own-state persistence only, no forms, popups, top navigation, or modals.
- Verify: load the embed with the network panel filtered on the guest origin, confirm the guest does its job, then confirm `window.top.location.href` is unreadable from the guest.
- Untrusted content (user-submitted embeds, ad creatives, sandboxed previews): drop `allow-same-origin` so the iframe origin is treated as `null` and cannot read its own cookies or storage.
- Verify: an untrusted preview iframe cannot read its own `document.cookie` (returns empty string) and cannot reach into the top-window storage.
### Allow-list via the `allow` attribute
- The `allow` attribute delegates Permissions-Policy features to the iframe; without explicit delegation the iframe cannot access camera, microphone, geolocation, payment, autoplay, or fullscreen even if its own origin would allow them.
- Delegate explicitly, e.g. `allow="geolocation 'self' https://map.example"` alongside `sandbox="allow-scripts allow-same-origin"`.
- Verify: enumerate every iframe and confirm each `allow` list contains only the capabilities the widget requires; a bare `<iframe>` with no `allow` is the correct default.
### Viewport coupling
Iframes do not size to content automatically. In order of preference:
- Fixed height (known size that stays): best for badges, ratings, small status widgets.
- Internal scrolling (iframe as a window into a scrollable region): acceptable for embedded apps, ugly for content.
- Resize via postMessage (guest reports content height, host adjusts): the only way to make an embedded form or article feel native. See "Viewport reporting" below.
### Theme inheritance
- CSS custom properties do not cross the iframe boundary; a host `--color-bg` is invisible to the guest. Theme via URL parameter or via postMessage.
- Theme via URL parameter (e.g. `theme=dark` or a color in the iframe `src`): coarse but simple and cacheable.
- Theme via postMessage (host sends a `theme` message after load, guest applies it): finer-grained and reactive to host theme changes. See "Theme adoption" below.
- Verify: switch the host theme and confirm the embedded iframe updates without a reload.
### Cross-origin storage post-3PCD
- After third-party cookie deprecation an iframe gets its own storage partition keyed by the top-level site, so the guest's cookies on widget.example from your site do NOT match its cookies on the same domain from a different host site.
- A widget that expects a shared session across multiple host sites cannot rely on its own cookies post-3PCD.
- The guest must request storage access via the Storage Access API on a user gesture (see auth.md).
- For widgets you control on both sides, use partitioned cookies (CHIPS) so the cookie carries `Partitioned` and lives per top-level site without third-party scope (see auth.md).
- Verify: in DevTools Application, the embedded widget's cookie shows a `Partition Key` matching the host top-level site, not a blank or shared partition.
### CSP `frame-src` discipline
The host CSP `frame-src` directive declares which origins may be embedded, e.g. `Content-Security-Policy: frame-src 'self' https://map.example https://widget.example;` (full CSP treatment in security.md).
- Never use `frame-src *` in production CSP.
- Use one `frame-src` directive listing every legitimate embed source, no more.
- When you remove a third-party embed, remove its origin from `frame-src` in the same commit.
- Verify: the deployed CSP `frame-src` lists exactly the iframe origins the page actually uses, with no leftovers from removed widgets.
## Embed as Guest: Your Widget Gets Embedded
Your widget runs inside someone else's iframe. The host controls the chrome, theme, viewport, and storage partition. Be a polite, robust guest.
### postMessage handshake protocol
Define a small, versioned protocol and enforce it on every message.
```js
const HOST_ORIGIN = 'https://host.example';
const PROTOCOL_VERSION = 1;
function send(type, payload) {
window.parent.postMessage(
{ protocol: 'partner-widget', version: PROTOCOL_VERSION, type, payload },
HOST_ORIGIN,
);
}
window.addEventListener('message', (event) => {
if (event.origin !== HOST_ORIGIN) return;
const msg = event.data;
if (!msg || msg.protocol !== 'partner-widget') return;
if (msg.version !== PROTOCOL_VERSION) return;
switch (msg.type) {
case 'theme': applyTheme(msg.payload); break;
case 'resize-request': send('viewport', getViewport()); break;
default: /* unknown type, ignore */
}
});
send('ready', { version: PROTOCOL_VERSION });
```
- Verify `event.origin` is the expected host on every inbound message.
- Discriminate messages by an explicit `protocol` field so other widgets on the host page do not collide.
- Negotiate version: if the host is on a newer protocol than the guest, ignore unknown types rather than guessing.
- Send `ready` once on init so the host knows it can begin sending theme and configuration.
- Verify: in the embedded context, every inbound `message` event handler returns immediately for any origin other than the configured host.
### Viewport reporting
Guest uses a `ResizeObserver` on the widget root, plus an initial report on `window` `load`.
```js
const root = document.getElementById('widget-root');
const ro = new ResizeObserver(() => {
send('viewport', { height: root.scrollHeight, width: root.scrollWidth });
});
ro.observe(root);
window.addEventListener('load', () => {
send('viewport', { height: root.scrollHeight, width: root.scrollWidth });
});
```
Host listens for `message`, returns unless `e.origin === 'https://widget.example'`, and on `e.data.type === 'viewport'` sets `iframe.style.height = `${e.data.payload.height}px``.
```js
const iframe = document.querySelector('iframe.partner');
window.addEventListener('message', (e) => {
if (e.origin !== 'https://widget.example') return;
if (e.data.type === 'viewport') {
iframe.style.height = `${e.data.payload.height}px`;
}
});
```
- Verify: open the embed and confirm that as content loads (images, async data) the iframe resizes to the content height without internal scrollbars.
### Theme adoption
Guest `applyTheme(theme)` iterates `Object.entries(theme)` and calls `document.documentElement.style.setProperty(`--${key}`, value)` for each token.
```js
function applyTheme(theme) {
const root = document.documentElement;
for (const [key, value] of Object.entries(theme)) {
root.style.setProperty(`--${key}`, value);
}
}
```
- Define a small, stable set of theme tokens (background, foreground, primary, border, radius, font-family) and document them as the contract.
- Refuse to read theme from anything other than a verified postMessage; never sniff the host's stylesheet.
- Verify: send a `theme` message with `--color-bg: #111` and confirm the widget background updates without a reload.
### Account-linked vs anonymous flows
- Anonymous embed: needs no login, no cross-site identity, no third-party cookies.
- Account-linked embed: either the host passes a short-lived token via postMessage after a host-side auth check, or the widget runs its own sign-in inside the iframe via Storage Access API plus partitioned cookies (CHIPS). See auth.md.
- Verify: identify each embed's mode. Anonymous embeds make zero auth calls; account-linked embeds either accept a host token on init or run their own gated sign-in.
### Width responsiveness without media queries
- Inside an iframe the viewport is the iframe's box, not the host viewport, so `@media (min-width: 600px)` answers whether the iframe is at least 600px, not the host page. Use container queries instead.
- Query the widget's own container:
```css
.widget {
container-type: inline-size;
}
@container (min-width: 480px) {
.widget-header { font-size: 1.25rem; }
.widget-actions { flex-direction: row; }
}
```
- Verify: resize the host browser window and confirm the widget reflows at its own breakpoints, not at the host viewport's breakpoints.
## Cross-Cutting Concerns
Applies to both host and guest.
### `X-Frame-Options` vs CSP `frame-ancestors`
- `X-Frame-Options` is the legacy framing header (`DENY`, `SAMEORIGIN`, `ALLOW-FROM`); CSP `frame-ancestors` is the modern replacement.
- Set `frame-ancestors` on every page that should not be framed.
- `frame-ancestors 'self'` permits embedding only on the same origin (the modern `SAMEORIGIN`).
- `frame-ancestors 'none'` blocks embedding entirely (the modern `DENY`).
- `frame-ancestors https://partner.example` permits embedding only on the listed origin.
- When `X-Frame-Options` and CSP `frame-ancestors` are both present and disagree, CSP wins in modern browsers.
- For sign-in pages, payment pages, and any page where clickjacking matters, send `frame-ancestors 'none'`; for the rest of the site set the policy explicitly even when the answer is `'self'`.
- Verify: the sign-in and payment routes return `Content-Security-Policy: frame-ancestors 'none'` and other routes return an explicit `frame-ancestors` directive listing the legitimate embedders.
### Top-window navigation from a sandboxed iframe
- A sandboxed iframe cannot navigate the top window by default; the browser ignores `window.top.location = ...` and `<a target="_top">` clicks from sandboxed content.
- `allow-top-navigation` permits any top navigation; use only for fully trusted content.
- `allow-top-navigation-by-user-activation` requires a user gesture; use it for widgets that need to navigate away on a click (sign-out, expand-to-fullscreen).
- Never combine `allow-scripts allow-same-origin allow-top-navigation` without `by-user-activation` on untrusted content: that is the classic clickjacking surface.
- Verify: a sandboxed widget with only `allow-scripts allow-same-origin` cannot navigate the host away, and adding `allow-top-navigation-by-user-activation` enables top navigation only inside click handlers.
### postMessage origin verification
NEVER process a `message` event without checking `event.origin`; return unless `event.origin === 'https://expected-origin.example'` before reading `event.data`.
```js
window.addEventListener('message', (event) => {
if (event.origin !== 'https://expected-origin.example') return;
// safe to read event.data
});
```
- Do not check `event.source` instead of `event.origin`; the source is a window reference and an attacker can race a different window into the slot.
- Do not allow-list by `endsWith('.example.com')`; an attacker registers `evilexample.com` and matches. Use exact match against an allow-list.
- Never skip the target-origin on outbound messages: `postMessage(data, '*')` broadcasts to any origin in the slot, so always specify the expected target origin.
- Verify: grep the codebase for every `addEventListener('message')` and `postMessage(`; each handler has an `event.origin` check before reading `event.data` and each `postMessage` call passes an explicit target origin, not `*`.
### Cross-origin isolation impact on embeds
Full COOP / COEP / CORP picture in security.md.
- If the host is cross-origin isolated (`Cross-Origin-Opener-Policy: same-origin` plus `Cross-Origin-Embedder-Policy: require-corp` or `credentialless`), its embedded resources must opt in.
- A cross-origin guest must send `Cross-Origin-Resource-Policy: cross-origin` (or compatible CORS headers) or the host will refuse to embed it.
- When `crossOriginIsolated === true` the guest gains access to `SharedArrayBuffer` and high-resolution timers.
- If the guest cannot satisfy COEP, the host loses cross-origin isolation OR the embed breaks: choose the one you want, not both.
- Verify: on a cross-origin-isolated host every embedded resource returns either CORS headers or `Cross-Origin-Resource-Policy: cross-origin`, and the host page's `crossOriginIsolated` is `true` in DevTools.
### Iframe loading discipline
Lazy-load below-the-fold iframes (the heaviest single resource on most pages), e.g. `<iframe src="https://widget.example/embed" loading="lazy" title="Customer support chat" sandbox="allow-scripts allow-same-origin">`. See performance.md for lazy-loading and third-party discipline.
- The `title` attribute is required for accessibility; screen readers announce iframes by title and a missing title falls back to "frame".
- Verify: every iframe has a meaningful `title`, every below-the-fold iframe has `loading="lazy"`, and lazy iframes do not request in the Network panel until the user scrolls near them.
### Accessibility of embedded content
Focus management and keyboard treatment in full live in accessibility.md.
- The iframe `title` describes what is inside ("Map of office locations", "Customer support chat"), not "iframe" or the widget vendor's brand.
- The iframe focus indicator must be visible; the host CSS controls the outline on the `<iframe>` element itself when the browser focuses it on Tab.
- Keyboard focus enters the iframe on Tab then continues inside it, and tabbing out the bottom returns to the host's tab order; the host MUST NOT trap focus around the iframe.
- For dialog-like embeds the guest's own focus trap applies inside the iframe while the host's outer dialog still owns the escape behavior.
- Verify: tab through a page with an embedded iframe: focus enters with a visible outline, tabs through internal controls, and exits cleanly with no focus traps at the boundary.
### Resource hints for known embeds
For embeds loading on most visits, hint with `<link rel="preconnect" href="https://widget.example" crossorigin />` and `<link rel="dns-prefetch" href="https://widget.example" />`. See performance.md for hint budgets.
- Use `preconnect` for the iframe origin AND any third-party origins it pulls from (CDN, analytics, fonts), one `preconnect` per origin, no more than four total.
- Use `dns-prefetch` as a cheap fallback on older browsers.
- Do NOT preload the iframe document itself; that costs more than it saves.
- Verify: on Network throttled to "Fast 3G", confirm the third-party iframe origin's connection is open before the iframe element starts requesting.
### Common embed regressions
| Symptom | Cause | Fix |
|---|---|---|
| Embed renders but is unstyled | Host's CSP `frame-src` allows the iframe but the guest's CSS host is blocked | Add the CSS origin to `frame-src` AND the guest's own CSP |
| Embed appears, no theme | Host did not send the `theme` message OR the guest does not handle the message type | Verify the postMessage handshake; log received types |
| Embed grows but never shrinks | Guest uses `scrollHeight` for resize; deleted content does not shrink the value | Switch to `getBoundingClientRect().height` or observe a known wrapper |
| Embed is blank on Safari | Third-party cookies blocked; guest requires same-origin storage | Request storage access on a user gesture; fall back to a top-level pop-out |
| Embed signs the user out on host theme change | Theme handler reloads the iframe | Apply theme via CSS custom properties only, never via reload |
| Console error: "Refused to display in a frame" | Embedded page sends `X-Frame-Options: DENY` or `frame-ancestors 'none'` | Cannot fix from the host; the guest must allow the host as an ancestor |
- Verify: walk through this common-embed-regression table during pre-launch for every third-party embed.
## Anti-Patterns
- Do not start from `sandbox=""` and add flags until "it works"; start from the minimum and justify each flag.
- Do not use `sandbox` with `allow-scripts allow-same-origin allow-top-navigation` on untrusted content (clickjacking surface).
- Do not send `postMessage(data, '*')` outbound; specify the target origin.
- Do not skip `event.origin` checks inbound (XSS surface).
- Do not use `endsWith('.example.com')` origin checks; use exact match against an allow-list.
- Do not ship an iframe with no `title` (inaccessible).
- Do not sniff the host's stylesheet to copy theme; use the documented handshake.
- Do not use media queries inside an embedded widget; use container queries.
- Do not lazy-load the iframe but eagerly load its `preconnect`; defer both or neither, consistent.
- Do not use `frame-src *` in production CSP; allow-list only.
- Do not assume first-party storage inside an iframe; plan for partitioning.
- Do not reload the iframe to apply a theme change.
## Self-Healing for Embed Patterns
- [ ] Every third-party iframe has a `sandbox` attribute with the minimum required flags
- [ ] Every third-party iframe has an `allow` attribute listing only required Permissions-Policy features
- [ ] Every iframe has a meaningful `title` for screen readers
- [ ] Below-the-fold iframes use `loading="lazy"`
- [ ] CSP `frame-src` lists exactly the origins actually embedded
- [ ] CSP `frame-ancestors` set on every embeddable page, with `'none'` on sign-in and payment
- [ ] postMessage handshake versioned and the guest sends `ready` on init
- [ ] Every `message` listener verifies `event.origin` against an exact allow-list
- [ ] Every `postMessage` call passes an explicit target origin, never `*`
- [ ] Guest reports viewport via `ResizeObserver` and host applies height on `viewport` messages
- [ ] Guest accepts `theme` messages and maps them to CSS custom properties without reload
- [ ] Guest uses container queries for responsiveness, not viewport media queries
- [ ] Cross-origin guests send appropriate `Cross-Origin-Resource-Policy` for isolated hosts
- [ ] Top-window navigation from a sandboxed iframe requires `allow-top-navigation-by-user-activation`
- [ ] Storage Access API requested only inside a user gesture, with CHIPS or pop-out fallback documented
- [ ] Focus enters and exits the iframe cleanly with no traps at the boundary
- [ ] Preconnect hints in place for known third-party embed origins
## See Also
- [security.md](security.md): CSP, COOP, COEP, CORP, Trusted Types, Permissions-Policy
- [auth.md](auth.md): Storage Access API, CHIPS, sign-in inside an embed
- [performance.md](performance.md): iframe lazy-loading, preconnect budgets, third-party script discipline
- [accessibility.md](accessibility.md): focus management, ARIA, keyboard navigation
references/observability.md
---
title: Frontend Observability
purpose: Post-deploy production practice covering source maps in production, error capture surfaces, sampled RUM, INP field attribution, the Reporting API, CrUX trend tracking, session replay tradeoffs, alert thresholds, and synthetic monitoring. Lighthouse measures a deploy; observability measures users, and one does not substitute for the other.
load-when:
task-keywords: [observability, RUM, monitoring, error capture, source maps, INP attribution, Reporting API, error boundary, CrUX, Long Animation Frames, LoAF, performance]
symptoms: [score dropped, INP regression, LCP regression, slow page, slow interaction]
prereq: SKILL.md
related: [performance.md, lighthouse.md, debug-recipes.md, pre-launch.md]
size: ~335 lines
---
# Frontend Observability
Lighthouse measures a deploy; observability measures users. Patterns are named at the standard level (`PerformanceObserver`, `Reporting-Endpoints` header, `sendBeacon`). Vendor tools are referred to generically (error tracker, paging system) so the standard, not a product, is the contract.
## Source Maps in Production
Minified production code turns every error into an opaque stack. Public maps disclose source. The answer is private upload.
Principle: build hidden source maps, upload them to the error tracker out of band, strip the public source-map URL from the bundle.
Config by toolchain:
- Webpack / Rspack: `devtool: 'hidden-source-map'`
- Vite / Rollup: `build.sourcemap: 'hidden'`
- esbuild: `sourcemap: 'external'` plus a script step to strip the `//# sourceMappingURL=` comment
The `hidden` variant emits `.map` files alongside the bundle but appends no `//# sourceMappingURL=` comment, so the browser never fetches them; the error tracker fetches them through its own upload pipeline (a CLI map-upload step).
Checks:
- `curl -sI https://your-cdn.example/static/main.<hash>.js.map` returns 403 or 404
- The bundle contains no `//# sourceMappingURL=` comment
- The error-tracker dashboard shows symbolicated stacks
- Production HTML does not link to `.map` files
### Source-map response header (cross-origin alternative)
When the error tracker self-hosts maps on a different origin, the `SourceMap:` HTTP response header on the JS resource points at the map URL, restricted to the error-tracker IP range at the edge (a WAF IP allow-list).
Example:
```text
GET /static/main.abc123.js
SourceMap: https://maps.internal.your-domain.example/main.abc123.js.map
```
Check: the map URL returns 200 only for the error-tracker egress IP range and 403 for everything else; the IP range is documented and refreshed on every vendor update.
### Never ship maps on the public CDN
Principle: a `main.js.map` on the public CDN is source code disclosure (original file paths, comments, inline-bundled secrets). The mistake is building with `devtool: 'source-map'` (the default in many configs) and deploying it to the same path as the bundle.
Check: a pre-deploy script asserts no `*.map` files in the public output directory (or asserts they are excluded from the CDN upload manifest); CI fails when the assertion fails.
## Error Capture Surfaces
A JS error reaches the tracker through one of four hooks. Wire all four; each catches a different surface.
| Hook | Catches | Note |
|------|---------|------|
| `window.onerror` | synchronous uncaught errors from any script, including inline | mandatory baseline |
| `unhandledrejection` | Promise rejections that never had a `.catch` | more than half of all reported errors in modern apps |
| Error boundary (component-tree) | render/commit throws in a subtree | renders fallback UI, forwards error |
| `ReportingObserver` | browser deprecations and interventions | never throw, never log to console outside DevTools |
`window.onerror`:
```js
window.addEventListener('error', (event) => {
reportError({
message: event.message,
source: event.filename,
line: event.lineno,
column: event.colno,
stack: event.error?.stack,
type: 'uncaught',
});
});
```
`unhandledrejection`:
```js
window.addEventListener('unhandledrejection', (event) => {
reportError({
message: event.reason?.message ?? String(event.reason),
stack: event.reason?.stack,
type: 'unhandled-rejection',
});
});
```
Check: a deliberate `Promise.reject(new Error('test'))` from the DevTools console arrives in the tracker within the expected sampling rate.
Error boundary: wire a component-tree boundary at every route and at every dangerous subtree (a chart, a third-party widget, a markdown renderer). It renders a fallback UI and forwards the error. Checks: every route has a boundary that renders a recovery UI without a full-page crash; boundary errors arrive with the route name and subtree identifier; a deliberate throw in a subtree does not blank the page.
`ReportingObserver`:
```js
const observer = new ReportingObserver((reports) => {
for (const report of reports) {
reportError({
type: report.type, // 'deprecation' or 'intervention'
message: report.body.message,
source: report.body.sourceFile,
line: report.body.lineNumber,
});
}
}, { buffered: true });
observer.observe();
```
It catches features removed from the platform, slow scripts the browser stopped, and autoplay blocked. Checks: registered before any third-party script runs; `deprecation` and `intervention` reports reach the tracker; the dashboard groups reports by source file.
## Sampled RUM
Lab numbers do not predict field numbers. Real-user monitoring is the only way to know what users experience. Use a field-metrics library exposing per-metric callbacks (`onLCP`, `onINP`, `onCLS`, `onFCP`, `onTTFB`) with an attribution entry point that adds per-metric debugging context (LCP element, INP event target, CLS source).
```js
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals/attribution';
const queue = [];
const flush = () => {
if (queue.length === 0) return;
navigator.sendBeacon('/rum', JSON.stringify(queue.splice(0)));
};
const send = (metric) => {
queue.push({
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
attribution: metric.attribution,
nav: performance.getEntriesByType('navigation')[0]?.type,
cls: metric.name === 'CLS' ? metric.entries.length : undefined,
});
};
onLCP(send); onINP(send); onCLS(send); onFCP(send); onTTFB(send);
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
addEventListener('pagehide', flush);
```
Three things this gets right:
- `sendBeacon` plus `visibilitychange` is the only reliable flush for the BFCache and tab-close case; `fetch` with `keepalive: true` is a backup but loses entries when the browser kills the request.
- Queue and batch: one beacon per page session, not one per metric, to reduce beacon overhead.
- Attribution payload: when INP is bad, the attribution object names the event target, the handler script, and the blocking time.
Check: the RUM endpoint receives entries with `attribution` populated; the dashboard ranks pages by p75 INP and lists top contributing elements per page.
### Sample rate
| Traffic | Sample rate |
|---------|-------------|
| High-traffic | 10 to 25 percent (plenty for trend tracking) |
| Low-traffic | 100 percent |
The sample decision happens once per session (`Math.random() < 0.1`), stored in a session-scoped variable, applied to all metrics for that session.
Check: sample rate is documented; beacon volume per day is tracked as a cost line item; sample rate is reviewed when traffic grows by more than 5x.
## INP Field Attribution Recipe
Field INP regressions are often invisible in the lab and are the leading score-dropped cause for 2024+ Core Web Vitals. Use two `PerformanceObserver`s: an `event` observer with `durationThreshold: 200` and a `long-animation-frame` observer with `durationThreshold: 50`.
```js
const slowEvents = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration < 200) continue;
queue.push({
type: 'slow-event',
name: entry.name,
duration: entry.duration,
processingStart: entry.processingStart,
processingEnd: entry.processingEnd,
target: entry.target?.tagName + (entry.target?.id ? `#${entry.target.id}` : ''),
});
}
});
slowEvents.observe({ type: 'event', durationThreshold: 200 });
const loaf = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.blockingDuration < 50) continue;
queue.push({
type: 'loaf',
duration: entry.duration,
blockingDuration: entry.blockingDuration,
scripts: entry.scripts.map((s) => ({
invoker: s.invoker,
sourceURL: s.sourceURL,
sourceFunctionName: s.sourceFunctionName,
forcedStyleAndLayoutDuration: s.forcedStyleAndLayoutDuration,
})),
});
}
});
loaf.observe({ type: 'long-animation-frame', durationThreshold: 50 });
```
The `event` observer skips entries with `entry.duration < 200` (200ms). The `long-animation-frame` observer (LoAF, Chrome 123+) skips entries with `entry.blockingDuration < 50` and reports every animation frame longer than 50ms with script attribution.
Mapping field INP to root cause:
- The metrics library reports INP value, rating, and the slow event entry (target, handler script).
- The LoAF observer, firing around the same time, names the scripts that ran in the blocking frame.
- Cross-reference by timestamp: the slow event plus the LoAF entries in the same animation frame are the same incident.
- `scripts[].sourceURL` and `scripts[].sourceFunctionName` give the file and function, resolved to original code via uploaded source maps.
Check: the dashboard, queried with "show me all field INP entries above 500ms in the last week", returns a ranked list grouped by `attribution.eventTarget` plus `scripts[].sourceFunctionName`, with actionable top entries.
## Reporting API
The Reporting API standardises browser-to-server delivery of CSP violations, deprecations, interventions, and network errors. It runs in parallel to the error tracker and catches things JS cannot see. The `Reporting-Endpoints` header (replacing the older `Report-To` header from 2024 onward) names destinations; CSP, NEL, Document-Policy, and Permissions-Policy violations each route to a named endpoint.
```text
Reporting-Endpoints: default="https://reports.your-domain.example/", csp-endpoint="https://csp.your-domain.example/"
Content-Security-Policy: default-src 'self'; ...; report-to csp-endpoint;
Document-Policy: ...; report-to=default
NEL: {"report_to":"default","max_age":604800}
```
Reports arrive as POSTed JSON arrays, content-type `application/reports+json`. Each report has `type`, `url`, `user_agent`, `body`, and a timestamp.
| Category | Trigger | Body fields |
|----------|---------|-------------|
| csp-violation | a blocked load or inline execution | `blocked-uri`, `effective-directive`, `original-policy` |
| deprecation | a feature scheduled for removal | deprecation id, message, source |
| intervention | a feature the browser disabled (autoplay, slow script) | intervention id |
| network-error | a failed resource load (DNS failure, TLS error, HTTP error) | protocol, method, status code |
Check: endpoints receive reports within minutes of a deploy violating an unrolled-out policy; the CSP report stream is monitored during rollout and goes silent before promotion to enforcing.
Browser support: Chromium and Edge ship the full API; Firefox supports CSP reporting and a subset of NEL; Safari ships CSP reporting through the older `report-uri` directive, so keep `report-uri` alongside `report-to` for coverage.
## CrUX and PageSpeed Insights
The Chrome User Experience Report (CrUX) is the public field dataset used for the Core Web Vitals ranking signal; the PageSpeed Insights (PSI) API exposes it per origin and per URL.
```bash
curl -s "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://your-domain.example&strategy=mobile&key=YOUR_KEY" \
| jq '.loadingExperience.metrics'
```
This returns p75 LCP, INP, CLS, FCP, TTFB over a rolling 28-day window. Each metric is categorised `FAST`, `AVERAGE`, or `SLOW`.
Gate: run the PSI query on every deploy and fail the deploy if any metric crosses from `FAST` to `AVERAGE` or worse, or if p75 INP grows by more than 50ms week-over-week.
Check: CI has a "perf budget" job that pulls CrUX and asserts the budget; the budget is documented per route in pre-launch.md evidence; regression alerts go to the on-call rotation.
Limitations: CrUX requires opt-in Chrome users, low-traffic origins return `null`, and authenticated routes behind login never appear, so RUM is the only signal for these.
## Session Replay Tradeoffs
Session replay records the DOM, network, and input so engineers can scrub a real session. Powerful for diagnosis, expensive in privacy surface.
Costs:
- Bytes: recording libraries are 30 to 100 KB gzipped on the wire; even deferred-init bundles are non-trivial.
- CPU: continuous DOM diffing and serialisation, modest but measurable in INP if the page is already close to the threshold.
- Storage: minutes of mutation logs per session.
- Privacy: recordings are personal data subject to right-of-access, right-to-erasure, lawful-basis disclosure, and data-processing agreements (GDPR surface).
PII redaction (defaults are not safe enough):
- Default block: treat every input, textarea, contenteditable, and `[data-pii]` element as masked by default; opt in to capture only for demonstrably safe fields.
- Network redaction: redact request and response bodies for endpoints that touch user data; keep headers minus Authorization and Cookie.
- URL redaction: strip query strings containing tokens such as `?access_token=` and `?reset=`.
- Console redaction: redact log lines matching known PII patterns (emails, credit-card-like, JWT-like).
Check: a deliberate session with a fake email and fake card number is replayed with both fields as redacted placeholders; the vendor data processing agreement names the PII categories captured and the retention.
Sample, do not capture everything: replay on 100 percent of error sessions and 1 percent of clean sessions.
## Alert Thresholds
Observability without alerts is a museum exhibit. Set thresholds, page on-call when they trip.
Page load failure rate (navigations that never reach a usable state):
| Threshold | Action |
|-----------|--------|
| < 0.5% | Healthy |
| 0.5% to 1% | Warning, investigate within a day |
| > 1% | Page on-call, treat as incident |
Measure: count navigations where `PerformanceNavigationTiming.loadEventEnd > 0` against total navigations, or distinct sessions with no successful page view against distinct sessions that started one.
JS error rate per session (sessions emitting at least one uncaught error or unhandled rejection):
| Threshold | Action |
|-----------|--------|
| < 1% | Healthy |
| 1% to 3% | Warning, file an issue |
| > 3% | Page on-call |
INP p75 vs CWV bar: the CWV bar for INP is 200ms; alert when origin p75 INP crosses 200ms (regression from passing) or grows by more than 20 percent week-over-week.
CWV passing rate per route: group routes by template (home, product, checkout) and track the passing rate as the fraction of sessions where all three CWVs (LCP, INP, CLS) hit the "Good" threshold; a drop is the leading indicator of a deploy regression before CrUX picks it up.
Check: alerts route to a paging system; each alert has a runbook in debug-recipes.md; alert fatigue is reviewed monthly.
## Synthetic Monitoring vs RUM
Synthetic and RUM measure different things; a robust story uses both.
Synthetic catches: availability (200 from a probe location), lab-Lighthouse performance under controlled conditions, regression of a critical user flow (login, checkout) under fixed browser and network, and third-party dependency outages.
RUM catches: real-user device distribution (slow Android on 3G), field-INP regressions invisible in the lab, regional performance variance, and real-user error rates (a widget crashing only on iOS Safari 17.2).
The split:
| Question | Use |
|----------|-----|
| Is the site up? | Synthetic |
| Did the latest deploy regress the home-page LCP? | Synthetic + RUM |
| Are users on slow Android phones suffering? | RUM |
| Did a third-party script start throwing yesterday? | Synthetic + RUM |
| What is the field INP distribution on the checkout page? | RUM |
| Did the staging deploy break the login flow? | Synthetic |
Synthetic cost is fixed per probe; RUM cost is proportional to traffic. Check: synthetic checks run every 5 minutes against the top three routes from at least three geographies; RUM captures every CWV and every error from at least 10 percent of sessions; both feed the same on-call dashboard.
## See Also
- [performance.md](performance.md) for lab-side instrumentation (DevTools Performance, Lighthouse, perf budgets) that pairs with field RUM
- [lighthouse.md](lighthouse.md) for `valid-source-maps`, `errors-in-console`, and audit rows that map to observability hygiene
- [debug-recipes.md](debug-recipes.md) for the named diagnosis loops that consume observability data
- [pre-launch.md](pre-launch.md) for the observability gate in the evidence manifest
references/pre-launch.md
---
title: Pre-Launch Checklist
purpose: The final verification gate before any public-visible page or interface ships. One checklist, every item runs on every change, every failure is a blocking defect.
load-when:
task-keywords: [pre-launch, checklist, ship, release, deployment, gate, verification, evidence, audit, lighthouse, screenshot]
symptoms: [score dropped, Lighthouse score drop, consent banner CLS, font swap CLS, noindex with sitemap]
prereq: SKILL.md
related: [lighthouse.md, audit-workflow.md, security.md, defects.md]
size: ~411 lines
---
# Pre-Launch Checklist
The final gate before a public-visible page or interface is declared complete.
## How to Use
- Walk top to bottom on every page that changed.
- For shared components, walk every page that consumes them.
- Never skip an item because it passed last time; regressions hide in unchanged areas.
- Mark "n/a" explicitly when a section does not apply (e.g., no forms); never silently skip.
- Treat any failure as a blocking defect.
## 1. Lighthouse (Production Build)
Run against the production build (not the dev server), mobile and desktop separately, at least 3 times, take the median.
```
<framework-build>
<framework-start-prod-server> -p 3001 &
export CHROME_PATH="$(find ~/Library/Caches/ms-playwright -name 'Google Chrome for Testing' -type f 2>/dev/null | head -1)"
# Mobile
npx lighthouse "http://localhost:3001/<path>" \
--output=json --output-path=/tmp/lh-mob.json \
--chrome-flags="--headless=new --no-sandbox --disable-gpu" \
--only-categories=performance,accessibility,best-practices,seo --quiet
# Desktop: same plus --preset=desktop, output to /tmp/lh-desk.json
npx lighthouse "http://localhost:3001/<path>" \
--output=json --output-path=/tmp/lh-desk.json --preset=desktop \
--chrome-flags="--headless=new --no-sandbox --disable-gpu" \
--only-categories=performance,accessibility,best-practices,seo --quiet
```
| Score | Mobile | Desktop |
|-------|--------|---------|
| Performance | >= 95 | >= 99 |
| Accessibility | 100 | 100 |
| Best Practices | 100 | 100 |
| SEO | 100 (n/a for noindex pages) | 100 (n/a for noindex pages) |
- [ ] If any category drops below the bar, identify the failing audit and fix per [lighthouse.md](lighthouse.md).
- [ ] Run BOTH programmatic sweeps and require both clean: geometry sweep (visual, headless browser) and content-and-markup sweep (Node over built HTML). See [defects.md](defects.md). A route is not signed off until both return clean.
- [ ] Verify the SHIPPED artifact, not the source: check static files and headers (`_headers`/redirects, `robots.txt`, `sitemap.xml`, security headers, manifest) in the built output directory and in the live response (`curl -I https://...`), not just the source tree. A correct source header that did not make it into the build is the classic "it works locally" header bug.
## 2. Core Web Vitals (Lab)
From the same Lighthouse run:
| Metric | Mobile | Desktop |
|--------|--------|---------|
| LCP | < 2.5s | < 2.0s |
| CLS | < 0.1 | < 0.05 |
| TBT | < 200ms | < 100ms |
| FCP | < 1.8s | < 1.0s |
| TTFB | < 800ms | < 600ms |
- [ ] INP is not in lab Lighthouse; verify in the field via `web-vitals` JS instrumentation.
## 3. Asset Budgets
- [ ] Initial JS payload (gzipped) < budget (default 90 KB mobile / 130 KB desktop)
- [ ] Initial CSS payload (gzipped) < budget (default 25 KB mobile / 35 KB desktop)
- [ ] Above-the-fold image budget met
- [ ] <= 2 font families, <= 4 weights total
- [ ] Third-party scripts within count and main-thread time budget
- [ ] If tooling supports `bundlesize`, `size-limit`, or a custom CI check, run it; treat budget violations as blocking
## 4. Accessibility
Automated:
- [ ] Lighthouse Accessibility = 100
- [ ] axe DevTools (or `@axe-core/playwright`) shows zero violations
- [ ] HTML validates (no broken markup)
Manual:
- [ ] Tab through the entire page in document order
- [ ] Every interactive element shows a visible focus ring with 3:1 contrast against surface and resting state
- [ ] Esc closes any open modal, popover, dropdown
- [ ] Forms: labels visible, required marked, error inline + announced, focus moves to first invalid (see [forms.md](forms.md))
- [ ] Modals: focus moves in on open, trapped while open, restored to trigger on close
- [ ] Screen reader pass on the primary user flow (VoiceOver or NVDA)
- [ ] Reading order matches visual order
- [ ] Headings: one H1, sequential, no skipped levels
- [ ] Images: every meaningful image has descriptive alt; decorative images have alt=""
- [ ] Links: descriptive text (not "click here")
- [ ] Skip-to-content link present and working
Visual accessibility:
- [ ] Body text contrast >= 4.5:1 in both light and dark mode (verified independently, not by inversion)
- [ ] Large text and meaningful UI graphics >= 3:1
- [ ] Color is never the only signal (status, error, success paired with icon or text)
- [ ] Page usable at 200% browser zoom with no horizontal scroll
- [ ] Page usable at largest system text size
## 5. Responsive
Test at every breakpoint and orientation:
- [ ] 320px (smallest supported phone)
- [ ] 375px (typical phone)
- [ ] 768px (tablet portrait)
- [ ] 1024px (tablet landscape, small laptop)
- [ ] 1280px (desktop)
- [ ] 1920px (large desktop)
- [ ] Phone landscape (short height)
- [ ] Tablet landscape
Per breakpoint:
- [ ] No horizontal scroll
- [ ] No clipped content
- [ ] Layout is intentional, not just fluid
- [ ] Standalone touch targets >= 44x44 with 8px gaps (mobile/tablet); inline text links are NOT inflated (WCAG 2.5.8 exception)
- [ ] Body text >= 16px on mobile (avoids iOS auto-zoom)
- [ ] Tables either fit, scroll horizontally, or transform to cards
- [ ] Sidebar / nav transforms appropriately
- [ ] Modals fit smallest viewport
- [ ] Safe areas respected (notch, dynamic island, gesture bar)
- [ ] `100dvh` (not `100vh`) for full-height mobile sections
- [ ] Mobile nav works with JavaScript disabled (a `<noscript>` fallback or static links that enhance)
- [ ] Modals and drawers lock body scroll AND compensate for scrollbar width (no sideways jump on open or close)
## 6. Theme
- [ ] Light mode designed and tested independently
- [ ] Dark mode designed and tested independently (not just inverted)
- [ ] All semantic tokens work in both modes
- [ ] Borders and dividers visible in both modes
- [ ] Focus rings visible in both modes
- [ ] Imagery looks correct in both modes (may need separate dark variants)
- [ ] Theme toggle (if present) works without flicker
- [ ] Default theme respects `prefers-color-scheme`
- [ ] User-selected theme persists across navigation
## 7. Motion
- [ ] All animations use `transform` and/or `opacity` (no `width`, `height`, `top`, `left`, `margin`)
- [ ] Durations 100-500ms (no animations longer than 500ms in routine UI)
- [ ] Exit faster than entrance (60-70%)
- [ ] `prefers-reduced-motion: reduce` removes or shortens non-essential animation
- [ ] Loading: skeleton at 300ms+, spinner before, progress for determinate
- [ ] Spinner / skeleton has accessible name or `aria-busy`
- [ ] Animations don't block input
- [ ] Animations don't cause layout shift (CLS)
- [ ] Continuous animations pause when off-screen and tab is inactive
## 8. Visual Quality
Typography:
- [ ] Maximum 2 font families, 4 weights total
- [ ] Distinctive choices (not just Inter on white)
- [ ] Type scale is consistent (multiplicative, not random)
- [ ] Body text 16px+ on mobile
- [ ] Line height 1.5-1.75 for body
- [ ] Line length 60-75 characters for prose
- [ ] Tabular figures for numeric columns
Color:
- [ ] Semantic tokens used (no raw hex in components)
- [ ] Dominant + 1-2 accents (not evenly distributed)
- [ ] Brand color contrasts properly in both modes
- [ ] Neutral scale used consistently for surfaces and text
Spacing:
- [ ] Spacing scale chosen (4-pt or 8-pt)
- [ ] No random spacing values
- [ ] Visual rhythm consistent
Iconography:
- [ ] One icon set throughout
- [ ] Consistent stroke width
- [ ] Consistent sizes per hierarchy level
- [ ] No emoji as structural icons (only as accents where intentional)
- [ ] SVG only (no PNG icons)
Composition:
- [ ] At least one section breaks the safe centered three-card layout (where appropriate to the brand)
- [ ] Visual hierarchy: scanning the page, the most important element is the largest/boldest
- [ ] Whitespace intentional, not just residual
- [ ] One primary CTA per screen
## 9. SEO (public-visible pages)
- [ ] Unique `<title>` 50-60 chars
- [ ] Unique `<meta name="description">` 140-160 chars
- [ ] One `<h1>` matching primary intent
- [ ] Sequential headings, no skipped levels
- [ ] Self-referencing `<link rel="canonical">`
- [ ] `<meta name="robots" content="index, follow">` (or `noindex` if not indexable)
- [ ] Open Graph tags (og:title, og:description, og:image 1200x630, og:url, og:type)
- [ ] Twitter card tags
- [ ] `<html lang="...">`
- [ ] Structured data validated via Rich Results Test (where applicable)
- [ ] Image alt text on every meaningful image
- [ ] Internal links use descriptive anchor text (not "click here")
- [ ] Page reachable from home in <= 3 clicks
- [ ] Listed in sitemap.xml (if indexable)
- [ ] HTTPS, no mixed content
- [ ] No render-blocking content critical to indexing
- [ ] Content-and-markup sweep over built HTML returns clean: one H1 per page, title and description in range (rendered length), self-referencing canonical, OG tags, every JSON-LD parseable, every image has alt, no duplicate ids, no orphan pages (except an error page)
- [ ] If AI discovery matters: `llms.txt` exists and is complete, robots.txt does not block major AI crawlers (and any dedicated user-agent group repeats all disallows), load-bearing facts are server-rendered
## 10. State Coverage
Every screen and component has intentional designs for:
- [ ] Empty (message and action, not blank)
- [ ] Loading (skeleton matching layout, or spinner)
- [ ] Success (brief feedback)
- [ ] Error (cause + fix + recovery action)
- [ ] Disabled (visually distinct, programmatically disabled)
- [ ] Initial / first-run (where applicable)
- [ ] Offline (network-dependent actions disabled, message shown)
- [ ] Limit reached (where applicable)
## 11. Forms (if forms changed)
- [ ] Every input has a visible, programmatic label
- [ ] Right `type` and `inputmode` for the data
- [ ] `autocomplete` per WHATWG spec (especially username/password/address/payment)
- [ ] Required fields marked visually + `aria-required`
- [ ] Validation on blur (not on every keystroke)
- [ ] Errors inline, near the field, with cause + fix
- [ ] Errors announced via `role="alert"` or `aria-live`
- [ ] On submit error, focus moves to first invalid field
- [ ] Submit button has loading state, disabled during submission, specific verb-noun label
- [ ] Helper text for complex fields
- [ ] Long forms autosave; "Saved" indicator visible
- [ ] Multi-step forms show progress and allow back navigation
- [ ] Destructive actions confirm or provide undo
- [ ] Tested with password manager / autofill
## 12. Charts and Tables (if data viz changed)
- [ ] Right chart for the data
- [ ] Axes labeled with units
- [ ] Colorblind-safe palette
- [ ] Color paired with shape/pattern/label
- [ ] Tooltip works on hover AND tap
- [ ] Text alternative (summary or accessible data table)
- [ ] Interactive elements keyboard-accessible
- [ ] Touch targets >= 44x44
- [ ] Responsive: simplifies on small screens
- [ ] Loading skeleton matches layout
- [ ] Empty state with message and action
- [ ] Error state with retry
- [ ] Numbers locale-formatted
- [ ] Tables: sorting, sticky header, mobile-adaptive
## 13. Cross-Browser
Test on at least:
- [ ] Latest Chrome (Chromium engine, what most users have)
- [ ] Latest Safari (WebKit, all iOS users + many macOS users)
- [ ] Latest Firefox (Gecko, smaller share but valuable diversity)
- [ ] Supported older browsers (per browserslist): spot-check key flows
Safari watch-list:
- [ ] `:has()` (now widely supported, but verify on older iOS)
- [ ] `<dialog>` (added in Safari 15.4)
- [ ] `view-transition` API (Safari 18+)
- [ ] Container queries (Safari 16+)
- [ ] Backdrop filter (use prefixed `-webkit-backdrop-filter`)
## 14. Real-User Monitoring
If RUM is set up:
- [ ] `web-vitals` library ships LCP, INP, CLS, FCP, TTFB to your pipeline
- [ ] Monitoring view shows p75 over rolling 28 days
- [ ] Alerts fire when p75 crosses thresholds
- [ ] Source maps uploaded for production error symbolication
## 15. Security Headers
Launch gate only; full policy authoring lives in [security.md](security.md). Verify the production response (`curl -I https://<host>`) carries every header below; source headers that did not make it into the build are the classic "it works locally" header bug.
- [ ] `Strict-Transport-Security` set with a long `max-age` (>= 1 year), `includeSubDomains`, and `preload` once the domain is HSTS-preload-listed
- [ ] `Content-Security-Policy` present, restrictive, no `unsafe-inline` for scripts; `frame-ancestors` set; report endpoint configured
- [ ] `Permissions-Policy` denies camera, microphone, geolocation, payment, USB, and other powerful features the page does not use
- [ ] `Referrer-Policy: strict-origin-when-cross-origin` (or stricter)
- [ ] `Cross-Origin-Opener-Policy: same-origin` (required for cross-origin isolation)
- [ ] `Cross-Origin-Embedder-Policy: require-corp` or `credentialless` (required for cross-origin isolation when high-resolution timers or SharedArrayBuffer are used)
- [ ] `Cross-Origin-Resource-Policy: same-origin` (or `same-site` for shared subdomain assets)
- [ ] `X-Content-Type-Options: nosniff` on every response
## 16. Privacy and Consent
- [ ] Cookie banner exposes a clear "Reject all" path with the same prominence as "Accept all" (no dark patterns, WCAG-readable contrast, no pre-checked non-essential categories)
- [ ] Reject parity verified: the reject button is equally clickable, equally large, and reaches the same dismiss endpoint
- [ ] Banner reserves its space (CLS-safe overlay): use `position: fixed` over reserved layout, or render server-side at known dimensions; verify CLS < 0.1 with the banner visible
- [ ] `Sec-GPC: 1` request header is honoured: treat it as an opt-out signal for non-essential cookies, analytics, and ad personalisation
- [ ] Analytics and non-essential third-party scripts load only AFTER explicit consent (or never when `Sec-GPC: 1` arrives); verify by loading in a fresh profile and watching the network panel before clicking "Accept"
## 17. Error-Tracking Sanity Check
- [ ] Source-map upload pipeline runs in the production build and posts maps to the error tracker's private endpoint (maps NOT served from the public origin)
- [ ] Throw a known error in production (e.g., a hidden test button that calls `throw new Error("prod-sanity-<commit-sha>")`) and confirm it lands in the error tracker within the expected window
- [ ] Confirm the symbolicated stack trace shows original file names, line numbers, and function names (not minified `t.aB.x`)
- [ ] Release tag (commit SHA or version) is attached to the captured event so it groups against the right deploy
## 18. i18n Smoke Test
Launch-time subset; full pipeline lives in [i18n.md](i18n.md).
- [ ] At least one non-Latin locale (Japanese, Arabic, Hindi, Greek) renders without falling back to a system font: inspect `font-family` resolution in DevTools on a glyph in the target script
- [ ] One RTL locale (Arabic or Hebrew) loads with mirrored navigation (logical properties for borders, padding, icons), no layout breaks, no clipped text, no LTR-only icons that lose meaning when flipped
- [ ] Numbers and dates use `Intl.NumberFormat` and `Intl.DateTimeFormat` against the active locale
- [ ] `<html lang>` and `dir` are set per route, not hardcoded to one value
## 19. Service Worker Kill-Switch Gate
If the site ships a service worker; full lifecycle lives in [pwa-offline.md](pwa-offline.md).
- [ ] A "unregister all SWs" page exists at a known stable path (e.g., `/_/unregister-sw`), deployable independently of the SW build, running `navigator.serviceWorker.getRegistrations().then(rs => rs.forEach(r => r.unregister()))` plus `caches.keys().then(ks => ks.forEach(k => caches.delete(k)))`
- [ ] The SW respects a `?nosw=1` query parameter on the entry navigation: when present, the SW does not handle the fetch (or unregisters itself) so a fresh request can bypass a broken cached shell
- [ ] The SW does NOT cache its own script with a long TTL: the SW file is served with `Cache-Control: max-age=0` (or `no-cache`) so the browser picks up a corrected SW on next revisit
- [ ] The SW versioning strategy is documented (cache name carries a build id; old caches deleted in `activate`)
## 20. Documentation
- [ ] Component docs updated (if applicable)
- [ ] Storybook / playground stories cover new states
- [ ] Design system tokens updated (if new tokens introduced)
- [ ] README / CHANGELOG updated (if significant change)
## 21. Git and Review
- [ ] Branch is up to date with main
- [ ] All changes are intentional (review the diff)
- [ ] No leftover console.log, debugger, TODO, FIXME without owner
- [ ] No leftover commented-out code
- [ ] Tests pass (unit, integration, e2e if applicable)
- [ ] Lint and format pass
- [ ] Type check passes
- [ ] Build succeeds for production target
## 22. Multi-Page Polish Gate
Launch-time subset; full Phase 19 acceptance list in [audit-workflow.md](audit-workflow.md) is authoritative when both gates apply. Any failure is blocking.
- [ ] Widget inventory exists for the affected widget families
- [ ] Same-purpose widgets extracted to one source of truth or normalized to one contract
- [ ] Drift collector returns no unexplained differences across pages for shared widgets
- [ ] Geometry sweep returns zero issues on every affected route at `1440x900` and `375x812`
- [ ] Before-and-after screenshots captured at both viewports for every affected route
- [ ] Reference comparison heuristics in [design.md](design.md) pass
- [ ] No same-purpose widget looks different on two routes without a named, intentional variant
## 23. Final Smell Test
Open in a fresh browser (incognito, no cached assets). Walk through as a new user:
- [ ] Is the primary action obvious within 2 seconds?
- [ ] Does the surface feel like a single product, or a collection of unrelated pieces?
- [ ] Does it look distinctive, or could it be any other product's equivalent screen?
- [ ] Would I be proud to share this URL?
If "no" to any, address it before declaring complete.
## 24. Evidence Manifest
The single list of artefacts the PR must produce. Link every item from the PR description; a reviewer must be able to re-run every check from the links alone. A PR missing any item is incomplete and review can be declined.
- [ ] Lighthouse JSON for mobile: file path (e.g., `audit/lh-mob.json`), median of >= 3 runs
- [ ] Lighthouse JSON for desktop: file path (e.g., `audit/lh-desk.json`), median of >= 3 runs
- [ ] axe JSON for every audited route: file path (e.g., `audit/axe-<route>.json`), zero violations
- [ ] Geometry sweep stdout: file path (e.g., `audit/geometry-<viewport>.txt`), zero issues at `1440x900` and `375x812`
- [ ] Content-and-markup sweep stdout: file path (e.g., `audit/content-markup.txt`), prints `NO PROBLEMS`
- [ ] Baseline screenshot directory at `1440x900` and `375x812`: file path (e.g., `audit/baseline/`)
- [ ] After screenshot directory at `1440x900` and `375x812`: file path (e.g., `audit/after/`)
- [ ] Per-route audit table (one row per route): issues found, fixes applied, before screenshot link, after screenshot link, format matches the table in [audit-workflow.md](audit-workflow.md) Phase 18
- [ ] Re-run instructions: the exact commands a reviewer runs to reproduce every artefact (build command, capture script, sweep invocations)
## When the Checklist Fails
Every item here has, at some point, been the regression that shipped. For each failure:
1. Identify the specific cause.
2. Fix at the right layer (component, page, system, or design token).
3. Re-run the relevant section (not the whole thing).
4. Once green, run a final full pass to verify nothing else regressed.
## See Also
- [lighthouse.md](lighthouse.md) for score-driven audit fixes
- [audit-workflow.md](audit-workflow.md) for multi-page polish work and the evidence-capture procedure
- [security.md](security.md) for header policy authoring
- [defects.md](defects.md) for symptom-to-fix lookup and the geometry sweep
references/print-email.md
---
title: Print and Email
purpose: Print stylesheets for invoices, receipts, and statements, plus transactional and marketing email HTML. Both surfaces demand a stricter rulebook than the screen.
load-when:
task-keywords: [print, email, "@page", page-break, transactional email, Outlook, accessibility, contrast]
symptoms: [score dropped, broken on Safari, dark mode broken]
prereq: SKILL.md
related: [design.md, ui-ux.md, accessibility.md, seo.md]
size: ~255 lines
---
# Print and Email
Print and email are first-class shipping surfaces. Invoices print, receipts save as PDF, transactional email lands in clients that have not changed their rendering engine in 15 years.
Core principle: design for the destination surface (print page, email client), not for the screen you wrote it on.
## Print Stylesheets
### Media block, chrome, content
| Principle | Check |
| --- | --- |
| Every print-relevant page has a `@media print` block | Block hides chrome and exposes content |
| Hide chrome | Hide: navigation, search bars, share buttons, floating action buttons, cookie banners, toast regions, social proof badges, anything below-the-fold unrelated to the printed artifact |
| Show link destinations | Show full URLs in links so the printed page is self-documenting |
| Suppress redundant URL suffix | Skip the printed URL suffix for fragment links (`#section`), `mailto:`, and `tel:`; the surface text already conveys the destination |
```css
@media print {
nav, header, footer, aside,
.banner, .share-button, .floating-action,
.skip-link, .cookie-consent, .toast-region {
display: none !important;
}
body {
color: #000;
background: #fff;
font-size: 11pt;
line-height: 1.4;
}
main {
max-width: none;
padding: 0;
}
a[href]:not([href^="#"]):not([href^="javascript:"])::after {
content: " (" attr(href) ")";
font-size: 90%;
color: #555;
}
}
```
### @page rules
The `@page` at-rule controls page size, margins, and layout outside the content box. Set document defaults with it.
| Need | Rule |
| --- | --- |
| Document default | `@page { size: A4; margin: 20mm 18mm; }` |
| US-letter sources | Replace `size: A4` with `size: letter` |
| Landscape (wide tables, charts) | `size: A4 landscape` |
### Page-break control
Use the modern property names `break-before`, `break-after`, `break-inside` in new code rather than the older `page-break-*` family. Use `break-inside: avoid` on any visually-coherent unit that should not split across pages: invoice line items, addresses, signature blocks, table rows, captioned figures.
```css
@media print {
h1, h2 { break-after: avoid; }
table, figure, .invoice-row { break-inside: avoid; }
.invoice-summary { break-before: page; }
}
```
### Color in print
Browsers strip backgrounds and images when printing by default. For surfaces where color is meaningful (status badges, brand bars, signature blocks), force color through with `print-color-adjust: exact`. The standard property is `print-color-adjust`; ship both `color-adjust` and `print-color-adjust` for compatibility with older Firefox.
```css
@media print {
.status-paid {
background: #d4edda;
color: #155724;
color-adjust: exact;
print-color-adjust: exact;
}
}
```
Plan the printed design to stay readable in monochrome; never rely on color alone to convey state (WCAG 1.4.1 use of color, see accessibility.md).
### Headers, footers, page numbers
```css
@page {
size: A4;
margin: 18mm;
@top-right {
content: counter(page) " / " counter(pages);
font-size: 9pt;
color: #666;
}
}
@page :first {
@top-right { content: ""; }
}
@page :left { margin-left: 22mm; }
@page :right { margin-right: 22mm; }
```
Browser support for `@page` margin boxes (`@top-right`, `@bottom-center`, etc.) is uneven: Chromium honours the page counter, Firefox honours much of the syntax, Safari covers a subset. For invoices that must render identically across browsers, use a server-side PDF generator (headless Chromium, Prince, WeasyPrint) rather than the browser print pipeline.
### Orphans and widows
Set `p, li { orphans: 3; widows: 3; }` in `@media print` to keep at least three lines together at each page break. Three is the conventional minimum; for body-heavy documents (legal, contracts) consider four.
### Print emulation and verification
Use DevTools print emulation: open the command menu (Cmd+Shift+P / Ctrl+Shift+P), run "Show Rendering", set "Emulate CSS media type" to "print". Run the emulation on every invoice, receipt, and report template before shipping.
Print-preview checklist: scroll every page and confirm headers and totals are visible, no line items split across pages, full URLs appear next to text-link references.
## Email HTML Constraints
### Client matrix and what you lose
| Client | Engine | What you lose |
| --- | --- | --- |
| Outlook 2007 to 2019 (Windows) | Word HTML engine | flex, grid, modern CSS, background images on most elements, `<style>` partly stripped |
| Outlook 365 (Windows) | Word HTML engine | Same as Outlook 2007 to 2019 |
| Outlook for Mac | WebKit | Mostly fine |
| Outlook.com (web) | Modern engine | Mostly fine, some `<style>` stripping |
| Gmail (web, app) | Modern, `<style>` stripped above size threshold | Inline CSS only is safe; media queries supported in `<style>` if it survives |
| Apple Mail (macOS, iOS) | WebKit | Mostly fine; dark-mode auto-inversion |
| Yahoo, AOL, ProtonMail | Mixed | Inline CSS, no JS, sandboxed |
| Mobile Gmail (Android, iOS) | Webview | Inline CSS plus responsive media queries |
Optimize for the "works everywhere" intersection.
### Table-based layout
Do email layout with `<table>` elements (single-cell centering wrapper, nested inner tables), not `<div>` plus flex or grid. Add `role="presentation"` on every layout table so screen readers do not announce them as data tables (see accessibility.md table semantics).
```html
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0">
<tr><td><!-- header --></td></tr>
<tr><td><!-- body --></td></tr>
</table>
</td>
</tr>
</table>
```
### Inline CSS
Inline every style directly on the element via `style="..."` since most clients strip or partially strip `<style>` blocks. Author templates with `class` attributes, then run a build-time CSS inliner (Juice for Node, Premailer for Ruby, Maizzle Tailwind-based, MJML) that converts classes to inline `style` declarations so shipped HTML has inline styles only. Keep one `<style>` block at the top of the document for media queries (responsive breakpoints, dark-mode overrides) since some clients honour media queries even when stripping layout selectors.
### Width and mobile stacking
The dominant canonical max-width for email layout is 600 pixels; stay at 600 unless content demands more, and test in every client. Achieve mobile responsiveness with a media query that turns side-by-side table cells into full-width stacked blocks.
```html
<style>
@media (max-width: 600px) {
.stack { display: block !important; width: 100% !important; }
}
</style>
```
### Dark mode
Apple Mail honours `prefers-color-scheme`; Outlook on Windows inverts with its own (often bad) algorithm; Gmail mostly does not honour the media query. Design legible in both modes, then add overrides for clients that honour them. For Outlook on Windows set `<meta name="color-scheme" content="light dark">` and accept partial inversion; test in Litmus or Email on Acid before shipping a sensitive template.
```html
<style>
:root { color-scheme: light dark; }
@media (prefers-color-scheme: dark) {
.bg { background: #1a1a1a !important; }
.text { color: #fafafa !important; }
.border { border-color: #444 !important; }
}
</style>
```
### Outlook conditional comments (VML)
Use Outlook MSO conditional comments wrapping a `v:rect`/`v:textbox` VML fallback around the anchor so Outlook renders the button as a vector shape while modern clients render the standard anchor.
```html
<!--[if mso]>
<v:rect xmlns:v="urn:schemas-microsoft-com:vml" fillcolor="#1a1a1a" stroke="f" style="width: 200px; height: 48px;">
<v:textbox inset="0,0,0,0">
<![endif]-->
<a href="https://your-domain/cta" style="display: inline-block; background: #1a1a1a; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px;">
Confirm
</a>
<!--[if mso]>
</v:textbox>
</v:rect>
<![endif]-->
```
### Images
| Rule | Detail |
| --- | --- |
| Absolute URLs | Use absolute URLs (`https://cdn.your-domain/img/logo.png`); email clients have no base path |
| No transparent PNG over color | Outlook adds a small black border on transparent edges; bake the background in or use a solid PNG |
| Meaningful `alt` | Every `<img>` has `alt` text that conveys meaning even if the image never loads (many clients block images until the user opts in) |
| Reserve space | Set `width` and `height` attributes on the `<img>` element |
| Retina | Use 2x resolution images scaled down with the width/height attributes |
| Size caps | Hard cap any single image at 100 KB and total payload at 500 KB; larger trips spam filters and Gmail clips messages over 102 KB |
### Tracking pixels and opt-out
A 1x1 tracking pixel is ethical for transactional and explicitly opted-in marketing but dubious for cold outreach and unsolicited messages.
```html
<img src="https://your-domain/o/pixel.gif?msg=abc123" width="1" height="1" alt="" style="display: block; border: 0;">
```
- When the `Sec-GPC: 1` request header (Global Privacy Control) is present, drop the tracking record on the server because the user signaled opt-out.
- Treat unsubscribe and tracking-opt-out as a single setting from the user's perspective; honouring one but not the other is a defect.
### Transactional vs marketing envelope
| Envelope | What it is | Consent | Footer |
| --- | --- | --- | --- |
| Transactional | Order confirmation, password reset, invoice, receipt, account notification | Implied by the user's action | Brief footer, support contact, account link |
| Marketing | Newsletter, promo, announcement, drip campaign | Explicit opt-in (GDPR), opt-out (CAN-SPAM) | Full mailing address, one-click unsubscribe, preferences link |
Keep transactional messages focused on the transaction; a transactional message that promotes other products becomes marketing and inherits marketing consent rules, so route promotional content through the marketing envelope.
Regulatory floors (minimums, not ceilings):
- CAN-SPAM (US): opt-out required, sender identification required, accurate subject lines, physical mailing address in the footer.
- GDPR (EU/UK): explicit opt-in for marketing, granular consent, easy withdrawal, data-access requests honoured.
- CASL (Canada): explicit opt-in for marketing, identification and unsubscribe required, retained consent records.
## See Also
- [design.md](design.md): typography and color decisions that translate into print color and email type scale.
- [ui-ux.md](ui-ux.md): unsubscribe and preference UX, notification etiquette governing marketing consent.
- [accessibility.md](accessibility.md): use-of-color rules (WCAG 1.4.1) for printed status badges and color-coded email.
- [seo.md](seo.md): canonical URL discipline for web versions of printed and emailed artifacts.
references/performance.md
---
title: Performance Deep Dive
purpose: Core Web Vitals discipline with exact thresholds, plus the levers for asset loading, render strategy, hydration, network, caching, fonts, images, INP field attribution, BFCache, and list virtualization. Framework agnostic.
load-when:
task-keywords: [LCP, INP, CLS, TTFB, performance, bundle, hydration, render strategy, BFCache, preload, fetchpriority, Speculation Rules]
symptoms: [LCP regression, INP regression, CLS regression, slow page, score dropped, bundle size grew, third-party script slow]
prereq: SKILL.md
related: [lighthouse.md, audit-workflow.md, defects.md, motion.md]
size: ~556 lines
---
# Performance Deep Dive
Lighthouse measures symptoms; this file covers causes and the levers you pull. Every optimization moves work between phases or eliminates it. The cheapest work is the work you do not do.
## The Loading Pipeline
- Network: DNS, TCP, TLS, HTTP (request to first byte). Lever: edge config, resource hints.
- HTML parse: browser discovers sub-resources. Lever: HTML structure, `<link>` order.
- CSS parse plus render-tree build: blocked by stylesheets in `<head>`. Lever: critical CSS.
- JS download plus parse plus execute: blocked by sync scripts. Lever: `defer` / `async` / module strategy.
- First paint, then LCP paint (largest in-viewport element finishes painting).
- Hydration / interactivity: JS attaches handlers, page becomes responsive (TBT/INP).
- Lazy phase: below-the-fold images, non-critical scripts, deferred islands.
## Core Web Vitals
### LCP
LCP is the render time of the largest text block, image, video poster, or background image in the initial viewport. Target: < 2.5s mobile, < 2.0s desktop.
Four phases and their levers:
| Phase | Typical share | Lever |
|-------|---------------|-------|
| TTFB (server response) | 25-40% | Edge caching, server location, route compute |
| Resource load delay | 0-30% | Preconnect, preload, critical-CSS inlining, font preload |
| Resource load duration | 30-60% | Compression, format (AVIF/WebP), responsive sizing |
| Render delay | 0-20% | Render-blocking JS/CSS, font swap, hydration cost |
Identify the LCP element: open DevTools, Performance, capture a load, find the LCP marker (the element is named).
If LCP is an image:
- Serve AVIF as the 2026 default (Baseline since 2024, supported in every evergreen engine). Keep a WebP source for older releases; keep JPEG only as the universal `<img>` fallback. Compression: AVIF q=50-65, WebP q=75-82.
- Use `<picture>` with type-keyed sources.
- Set `width` and `height` (or `aspect-ratio`) to prevent CLS.
- Add `fetchpriority="high"`.
- Never `loading="lazy"` on the LCP image.
- Use `srcset` with at least 3 widths plus `sizes` matching the rendered box.
- Preload only when the image is rendered by client JS (CSR/hydration); a server-rendered `<img>` does not need preload.
If LCP is text:
- Preload the font that renders it: `<link rel="preload" as="font" type="font/woff2" crossorigin>`.
- Set `font-display: swap` so the fallback shows immediately.
- Use `size-adjust`, `ascent-override`, `descent-override`, `line-gap-override` on `@font-face` to match metrics to the fallback and prevent CLS on swap.
- Avoid web fonts entirely for the largest hero text if possible (use a system stack).
If LCP is a video poster: treat as image, add the `poster` attribute, declare dimensions.
Reduce TTFB (see TTFB section): cache HTML at the edge with `s-maxage` plus `stale-while-revalidate`; move compute close to users (edge functions, regional deploy); stream HTML rather than block origin compute on slow data fetches.
### INP
INP measures the slowest interaction during the page lifetime. Target: < 200ms (good), < 500ms (needs improvement). It is dominated by long tasks during the interaction (event handler > 50ms), tasks blocking the next paint, sync layout reads, and large framework re-renders.
Levers:
- Break long tasks with `scheduler.yield()` (fallback `setTimeout(fn, 0)` / `requestIdleCallback`) to chunk work.
- Move expensive work off the main thread (Web Workers, OffscreenCanvas).
- Avoid sync layout thrash: batch reads then writes (read all `getBoundingClientRect()` once, then write).
- Memoize expensive renders (component-level memo/compute primitives per framework, e.g. memo + derived/computed values).
- Use CSS for state when possible (`:hover`, `:active`, `:focus-visible`, `:has()`, `:checked`, popover API) instead of JS state updates.
- Virtualize large lists (see List Virtualization).
- Debounce expensive input computations (search, validation) with a 150-300ms debounce.
INP attribution recipe (field side). Lab Lighthouse cannot meaningfully score INP; attribute from real users with two sampled observers shipped via `sendBeacon`:
- `PerformanceObserver` on entry type `event` gives every interaction's duration, target, and processing time. Filter `duration > 40ms` to keep volume manageable.
- `PerformanceObserver` on `long-animation-frame` (LoAF) gives the frame that paid for the next paint, including the scripts that ran, their source URLs, and forced-style/forced-layout work.
- A web-vitals attribution build packages both: `onINP` returns `attribution` with `targetElement`, `eventTarget` (a CSS selector), `eventType`, `loadState`, and the longest LoAF script `sourceURL`. Ship that, not just the metric value.
```html
<script type="module">
import { onINP } from 'https://unpkg.com/web-vitals@4/attribution?module';
onINP((metric) => {
const a = metric.attribution;
navigator.sendBeacon('/rum/inp', JSON.stringify({
value: metric.value,
rating: metric.rating,
eventTarget: a.eventTarget,
eventType: a.eventType,
loadState: a.loadState,
longAnimationFrame: a.longAnimationFrameEntries?.[0]?.scripts?.[0]?.sourceURL,
}));
}, { reportAllChanges: false });
</script>
```
The two highest-signal columns: `eventTarget` (which control is slow) and `longAnimationFrame.scripts[0].sourceURL` (which script paid the cost). Together they point at the fix.
### CLS
CLS sums layout-shift scores during the page session. Target: < 0.1 (mobile), < 0.05 (desktop). Common sources: images/iframes/embeds without dimensions, late-loading fonts (FOUT), late-injected banners and ads, layout-affecting animations, scrollbar appearance changes.
Fixes:
- Declare `width` and `height` on every `<img>`, `<iframe>`, `<video>`; use `aspect-ratio` for fluid layouts.
- Reserve space for late content with `min-height` or skeletons.
- Cookie/consent banners go below the fold or as a fixed-position overlay, not in document flow.
- For font swap, use `size-adjust` and the `*-override` properties on `@font-face` to size-match the fallback so the swap is invisible.
- Animate only `transform` and `opacity`; never animate layout-affecting properties.
- For dynamic content (search results), set `min-height` on the container so the page does not grow as results arrive.
### TTFB
TTFB is request to first byte received. Target: < 800ms mobile, < 600ms desktop.
- Edge cache HTML where possible (SSG, ISR, edge functions).
- Use a CDN with global PoPs.
- For dynamic pages, stream the response so the first byte arrives before all data is fetched.
- Move compute to the edge.
- Profile the server route for serial DB calls, slow third-party APIs, and cold starts.
## JavaScript Strategy
### Render strategies
The render-strategy decision tree (SSG, ISR, SSR, streaming SSR, CSR, islands, resumability) lives in SKILL.md under `Decision: Render Strategy`. Decide there first, return here for levers.
### Streaming SSR shell-plus-stream envelope
Streaming SSR beats classic SSR (slow TTFB) and CSR (slow LCP) when the page depends on slow data.
- The server flushes a complete HTML shell first (`<head>`, critical CSS, navigation, footer, layout containers); nothing in the shell awaits data.
- Wrap slow regions in Suspense (or the framework's equivalent boundary) as placeholders in the shell.
- As each async data fetch resolves, the server streams an HTML chunk plus a tiny inline script that replaces the placeholder in place, no client round trip.
Rules that make the envelope work:
- The `<head>` must not wait: no data-dependent `<title>`, `<meta>`, or canonical in the shell stream; resolve statically per route or stream as out-of-order chunks.
- Name Suspense boundaries per UX intent (around "Reviews", "Related products"), not per technical boundary.
- The shell is the LCP candidate, not the streamed regions: put the hero image in the shell; everything below the fold can stream.
- Verify with a slow-network capture (DevTools, Slow 3G): the shell paints fast and regions fill in; a blank page until everything resolves means a boundary is misplaced.
### Hydration cost
Hydration cost is roughly bundle parse plus module init plus component constructor plus diff against existing DOM.
- Server components ship no JS at all; use them for static structure.
- Client-component boundaries (the framework's "use client" or equivalent) should be small leaves, not big trees.
- Lazy hydrate below-the-fold islands with a Suspense boundary plus dynamic import or framework helpers.
- Island-by-default frameworks: mark client components per criticality with idle / visible / load directives.
- Resumable frameworks: resumability instead of hydration, components do not re-execute on the client unless the user interacts.
- SSR-plus-selective-client frameworks: head injection helpers plus route-level server data files.
- Partial-hydration island wrappers exist in most SSR frameworks; SSR-plus-reactive-primitive frameworks keep hydration cost low.
### Bundle splitting
Default splits: per route, per dynamic import (lazy-loaded modal, chart, editor), per third party (vendor chunk). Anti-patterns:
- One giant vendor bundle: split heavy libs (chart, editor, video player) so they load only on pages that use them.
- Importing default exports of barrel files (prevents tree-shaking): import named exports directly, `import { foo } from 'lib/foo'`, not `import { foo } from 'lib'`.
- Dev-only code shipped to production: wrap with `process.env.NODE_ENV !== 'production'` (or framework equivalent) so the bundler tree-shakes it out.
### Tree-shaking
- Use ES modules end to end (`"type": "module"` or `.mjs`).
- Mark library `package.json` with `"sideEffects": false` (or list specific files with side effects).
- Avoid importing entire libraries: `import _ from 'lodash'` blocks tree-shaking; use `import debounce from 'lodash/debounce'` or a native replacement.
## Image Strategy
Format priority:
| Pri | Format | When | Note |
|-----|--------|------|------|
| 1 | AVIF | Photographic content | ~50% smaller than JPEG at equal quality |
| 2 | WebP | Fallback for older browsers (Safari < 16) | ~30% smaller than JPEG |
| 3 | JPEG | Final fallback | mozjpeg quality 75-82, progressive=true |
| 4 | PNG | Transparency AVIF/WebP cannot handle (rare) | |
| 5 | SVG | Icons, logos, illustrations | inline when small (< 1 KB), reference when large |
Images inside a hydrated island still must be optimized at the build-time image step, not shipped raw because they happen to live in a component.
Every non-trivial image needs `srcset` and `sizes`:
```html
<picture>
<source
type="image/avif"
srcset="/img/hero-480.avif 480w, /img/hero-960.avif 960w, /img/hero-1440.avif 1440w"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 1280px"
/>
<source
type="image/webp"
srcset="/img/hero-480.webp 480w, /img/hero-960.webp 960w, /img/hero-1440.webp 1440w"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 1280px"
/>
<img
src="/img/hero-960.jpg"
width="1440"
height="900"
alt="..."
fetchpriority="high"
decoding="async"
/>
</picture>
```
Do not ship a high-resolution master inline for a thumbnail. Serve small responsive `srcset` widths (for example 640, 960, 1280) for the inline thumbnail; load the full-resolution master only when the user opens the fullscreen view. The inline image and the fullscreen image are two sources for the same asset.
Lazy loading:
- `loading="lazy"` on every `<img>` and `<iframe>` below the fold.
- `<iframe loading="lazy">` is the cheapest single win for embeds: a typical video embed costs 500 KB to 1 MB plus JS execution; deferring it past first paint can recover 10 to 20 Lighthouse points on a content page.
- Never `loading="lazy"` on the LCP image.
- For video, use `preload="metadata"` and lazy-load the actual video on user interaction.
Aspect-ratio reservation for fluid layouts (reserves space, prevents CLS, no hardcoded pixels):
```css
.hero-image {
aspect-ratio: 16 / 9;
width: 100%;
height: auto;
}
```
## Font Strategy
Web fonts are one of the largest regressions any surface can absorb. Budget them.
- Maximum 2 font families per page (1 display plus 1 body, or 1 family with multiple weights).
- Maximum 4 weights total.
- Subset to the languages you actually serve (Latin, Latin-extended, Cyrillic, etc.).
- Use variable fonts when available; one variable file replaces 3-5 static weights.
- Self-host; a hosted font CDN adds an extra DNS lookup, self-hosted is faster.
Preload the critical body font, woff2 only:
```html
<link rel="preload" as="font" type="font/woff2" href="/fonts/body-regular.woff2" crossorigin />
```
Preload the asset the build actually emits: resolve the fingerprinted href from the build manifest, do not hardcode the filename, or the preload silently does nothing and LCP regresses with no error. Same rule for any preloaded image or script.
```css
@font-face {
font-family: 'BrandBody';
src: url('/fonts/body-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
/* CLS-prevention: match metrics to a fallback */
size-adjust: 100%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
```
- `font-display: swap` for body fonts: fallback then swap (slight FOUT, no FOIT).
- `font-display: optional` for display fonts on poor networks: shows fallback only if the font is not ready in 100ms. Best for LCP.
- `font-display: block` is forbidden in production (causes invisible text / FOIT).
## CSS Strategy
Critical CSS: the first 14 KB of HTML fits in one TCP round trip; inline the CSS needed to render the first viewport there and defer the rest. Two approaches:
- Build-time critical-CSS extraction (works for static pages).
- Component-scoped CSS that ships only what is used (CSS Modules, zero-runtime CSS-in-JS, JIT utility CSS); works for any framework.
Defer non-critical CSS:
```html
<link
rel="preload"
href="/css/below-fold.css"
as="style"
onload="this.rel='stylesheet'"
/>
<noscript><link rel="stylesheet" href="/css/below-fold.css" /></noscript>
```
Alternative:
```html
<link rel="stylesheet" href="/css/below-fold.css" media="print" onload="this.media='all'" />
```
Avoid CSS bloat:
- Run a production purge step.
- Avoid loading entire UI-library themes; import only what is used.
- Use logical properties (`margin-inline`, `padding-block`) so RTL works without duplicating rules.
## Network Strategy
### Resource hints
Use sparingly; every hint costs a connection slot.
| Hint | Cost / use |
|------|------------|
| `dns-prefetch` | Lowest cost, resolves DNS only; for low-priority third parties |
| `preconnect` | DNS + TCP + TLS; for high-priority third parties (your CDN, your API origin) |
| `preload` | Forces an early fetch; only for critical resources (LCP image, critical font) |
| `prefetch` | Low-priority hint for next-page resources; for likely next navigation |
| `modulepreload` | For ES module dependencies |
Anti-patterns: preloading every font weight (pick one or two); preconnecting to 10+ origins (pick three); preloading images already discovered in the HTML (wasteful).
### fetchpriority beyond images
`fetchpriority` hints the browser's resource scheduler and applies to `<link>`, `<script>`, `<img>`, and `fetch()`.
- `fetchpriority="high"` on the critical CSS preload (`<link rel="preload" as="style" fetchpriority="high">`) so it outranks images and fonts.
- `fetchpriority="high"` on the hero font preload and on the critical JS module the LCP depends on.
- `fetchpriority="low"` on every third-party script tag, every below-the-fold image preload, and any analytics/ads `fetch()`.
- Pair `<script async fetchpriority="low">` for third-party tags and audit the Network panel "Priority" column to confirm the hint took effect.
### Speculation Rules API
The page tells the browser which next-navigations to prefetch or prerender; the browser runs a hidden background page and swaps it in on click.
```html
<script type="speculationrules">
{
"prerender": [
{ "source": "list", "urls": ["/pricing", "/docs"], "eagerness": "moderate" }
],
"prefetch": [
{ "source": "document", "where": { "href_matches": "/blog/*" }, "eagerness": "conservative" }
]
}
</script>
```
- `prerender` runs the next page (HTML, CSS, JS) in the background; instant navigation but costly, use for the two or three most likely next pages.
- `prefetch` downloads bytes but does not execute; cheaper, use where prerender would be wasteful.
- `eagerness` `eager`: fire as soon as the rule is parsed (one or two near-certain pages only).
- `eagerness` `moderate` (default for hover/focus): fire on hover or focus of a matching link.
- `eagerness` `conservative`: fire on `pointerdown`; cheapest hint with the most user-intent signal.
Anti-patterns: prerender every link (burns CPU/bandwidth, blocks BFCache); prerender pages with auth side effects in the loader; prerender pages that mutate analytics on load (double-count).
### Early Hints (HTTP 103)
HTTP 103 Early Hints let the CDN send `Link: <...>; rel=preload` headers before the origin returns the HTML, so the browser preloads critical CSS, hero image, and key font during otherwise dead TTFB.
```http
HTTP/1.1 103 Early Hints
Link: </css/critical.css>; rel=preload; as=style
Link: </fonts/body.woff2>; rel=preload; as=font; type=font/woff2; crossorigin
Link: </img/hero.avif>; rel=preload; as=image; fetchpriority=high
```
The `200` follows with the full HTML. The win is 200 to 400ms of LCP recovered on slow-origin pages and zero on already-fast pages; prioritise where TTFB dominates LCP.
### BFCache hygiene
A BFCache-qualifying page returns in under 100ms; a non-qualifying page pays the full reload cost. Rules to qualify:
- Replace `unload` listeners with `pagehide` and `visibilitychange`; any `unload` listener disqualifies the page. The Lighthouse `no-unload-listeners` audit catches it; verify third-party scripts too.
- Audit `Cache-Control: no-store` on HTML responses; `no-store` disqualifies BFCache. Reserve it for genuinely sensitive routes.
- Close open `WebSocket` and `IndexedDB` transactions on `pagehide`; an open connection disqualifies the page in some engines.
- Avoid `Cache-Control: no-cache` plus a redirect; the round trip defeats the purpose.
Verify with the `NotRestoredReasons` API: in the `pageshow` handler, `event.notRestoredReasons` returns the structured list of blockers (with `src`, `reason`, `url`). Ship it through RUM.
```html
<script type="module">
window.addEventListener('pageshow', (event) => {
if (event.persisted) return; // BFCache hit; nothing to log
const reasons = (event.notRestoredReasons || {}).reasons || [];
if (reasons.length) {
navigator.sendBeacon('/rum/bfcache', JSON.stringify({
url: location.pathname,
reasons,
}));
}
});
</script>
```
### Compression Dictionaries
The `Use-As-Dictionary` and `Available-Dictionary` headers (shared Brotli) let a repeat visitor reuse a previous response's bytes as the dictionary for the next compression.
- Serve the first response with `Use-As-Dictionary: "<id>"`; the browser stores it.
- On the next response for the same resource family, the browser sends `Available-Dictionary: <id>` and the origin (or CDN) emits a shared-Brotli body keyed to that dictionary.
- Wire bytes drop by 70 to 95 percent on small updates.
Use for high-traffic, slowly-changing assets (framework runtime bundles, design-system CSS, large JSON config blobs); verify CDN support and fall back to standard Brotli where unsupported. Pure win for repeat visitors, zero cost for first-time visitors.
### ESM-only upgrade path
On an evergreen-only target:
- `<script type="module">` everywhere; drop the nomodule pair.
- import maps (`<script type="importmap">`) resolve bare specifiers in the browser without a bundler; pair with a CDN serving stable, versioned modules.
- Module workers (`new Worker(url, { type: 'module' })`) share code with the main thread via `import` rather than `importScripts`.
- Set `browserslist` to evergreen-only (`['>0.5%', 'not dead', 'not op_mini all', 'not ie 11']`) so the bundler stops emitting legacy transforms; typical savings 15 to 30 KB gzipped on the initial bundle.
### Transport and compression
- HTTP/2 multiplexing means many small requests are fine; do not over-bundle.
- HTTP/3 (QUIC) reduces handshake latency; most modern CDNs support it, verify with `curl --http3`.
- Brotli for static assets (~20% better than gzip on text); gzip as fallback; no compression for already-compressed assets (images, video, woff2).
### Caching
| Asset | Cache-Control |
|-------|---------------|
| Versioned static (`/static/abc123.js`) | `public, max-age=31536000, immutable` |
| Versioned image | `public, max-age=31536000, immutable` |
| HTML | `public, max-age=0, s-maxage=300, stale-while-revalidate=86400` |
| API JSON (read-mostly) | `public, max-age=30, s-maxage=60, stale-while-revalidate=300` |
| API JSON (private / per-user) | `private, max-age=0, must-revalidate` |
| Service worker | `no-cache` |
## Third-Party Strategy
Third-party scripts are the silent assassin of Lighthouse scores. Treat them as a budget item.
Audit each third party: what it does (analytics, ads, chat, embed, A/B test, error tracking), bytes (compressed), main-thread time (Lighthouse `third-party-summary`), whether it blocks rendering, whether removal is acceptable.
Mitigations:
- Remove first: most pages have at least one third party that contributes nothing measurable.
- Lazy-load on idle: wrap with `requestIdleCallback` or load after the `load` event.
- Lazy-load on interaction: chat widgets load on hover/click of the trigger, not on page load.
- Move to a worker: run analytics/ads on a Web Worker (worker proxy tooling) so they do not compete for the main thread.
- Self-host: a tag manager can be proxied through your own origin to remove the extra connection.
- Use a server-side equivalent: page-view analytics via a measurement protocol; ad attribution server-side; A/B tests edge-side.
Budget, enforced in CI via Lighthouse `third-party-summary`:
- At most 5 distinct third-party origins per page.
- Total main-thread time at most 250ms on the mobile profile, ideally under 150ms. A page outside the budget is a defect, not a tradeoff.
Run the monthly audit on the first Monday: fresh Lighthouse capture of the top three traffic routes, open `third-party-summary`, list every script with its main-thread cost, and decide keep / defer / sandbox / remove for each.
Load mode by intent:
| Script intent | Loads on | How |
|---------------|----------|-----|
| Analytics (page-view ping) | `requestIdleCallback` after `load` | `<script defer fetchpriority="low">`, tag manager or first-party proxy |
| Error tracking | Page boot | `<script async fetchpriority="low">`, must capture early errors, cannot fully defer, keep and budget it |
| A/B test flicker-prevention | Synchronous in `<head>` | `<script>` with a strict timeout; render-blocking flicker-prevention is a known LCP killer, prefer server/edge-side experimentation |
| Chat / support widget | User intent (hover or click of trigger) | Dynamic `import()` in the trigger handler; deferring saves 200 to 500 KB |
| Display ads | After main content paints | `requestIdleCallback` plus IntersectionObserver, lazy per slot within one viewport |
| Heavy analytics / marketing tags | Web Worker (worker proxy) | Move the tag off the main thread via a worker-proxy runtime; verify it tolerates worker context (no DOM access) |
Sandbox the embed. Third-party embeds (video players, social timelines, maps, share buttons) ship inside an `<iframe sandbox>` with the minimum `allow-*` flags:
```html
<iframe
src="https://video-host.invalid/embed/<id>"
title="<descriptive title>"
loading="lazy"
sandbox="allow-scripts allow-same-origin allow-presentation"
allow="autoplay; fullscreen; picture-in-picture"
referrerpolicy="strict-origin-when-cross-origin"
></iframe>
```
The `sandbox` attribute denies storage, navigation, and pop-ups by default and re-grants only what the embed needs; the `allow` attribute (Permissions Policy on the frame) gates powerful APIs. Full embed-as-host playbook in embed-patterns.md; cross-origin isolation and CSP for embedded scripts in security.md.
Isolate analytics on a first-party subdomain with a separate cookie scope.
## List Virtualization and Infinite Scroll
A list that ever exceeds 100 to 200 items, or any list with rich children (cards with images, charts), is a virtualization candidate. Cost model: 1000 DOM nodes is heavy, 10000 is broken; Lighthouse `dom-size` fails at 1500. Virtualize when the list can grow past 200 items, each row has more than 3 DOM nodes, or the list is the LCP or near it.
Two framework-agnostic strategies:
- Windowing libraries (framework-native or standalone) render only viewport rows plus a small overscan buffer and recycle DOM nodes; best for very long lists, complex rows, or precise row-height control.
- CSS `content-visibility: auto` with `contain-intrinsic-size`: the browser skips render and layout for off-screen blocks, no JS; best for medium-long lists of similar-sized blocks.
```css
.card {
content-visibility: auto;
contain-intrinsic-size: 0 320px; /* width auto, expected height 320px */
}
```
`contain-intrinsic-size` must be close to the real rendered height (use the median measured height) or the scrollbar jumps as cards render.
Infinite scroll:
- IntersectionObserver on a sentinel at the list's end triggers the next-page fetch (cheap, debounced, native).
- API uses cursor pagination (`?cursor=<opaque_id>&limit=20`), not offset (`?offset=400`); cursors are stable across inserts, offsets are not.
- Cache loaded pages in memory keyed by cursor; back-navigation should not refetch.
- Persist scroll position and loaded-page set in `history.state` on every page load; on back navigation (`navigation.type === 'back_forward'` or BFCache miss) restore both.
- Set `<html style="scroll-behavior: auto">` for the restore (smooth scroll defeats it).
Accessibility of virtualized rows (full treatment in accessibility.md): set `role="grid"` or `role="list"` on the container; set `aria-rowcount="<total>"` (the full total, not the rendered count) on the container and `aria-rowindex="<n>"` (1-indexed position in the full list) on each rendered row; keyboard up/down at a window edge must trigger a scroll that loads the next row before focus moves to it, or focus disappears into a recycled node.
## Performance Budgets in CI
Lighthouse CI assertions are covered in lighthouse.md. Add a bundle-size guard.
Framework budgets:
```json
"budgets": [
{ "type": "initial", "maximumWarning": "120kb", "maximumError": "160kb" },
{ "type": "anyComponentStyle", "maximumWarning": "20kb" }
]
```
Granular file-size checks (CI fails the PR if budgets are exceeded):
```json
{
"size-limit": [
{ "path": "dist/main.js", "limit": "90 KB" },
{ "path": "dist/main.css", "limit": "25 KB" }
]
}
```
## Real-User Monitoring
Lab numbers are necessary but not sufficient. Instrument production:
```html
<script type="module">
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'https://unpkg.com/web-vitals@4?module';
const send = (m) => navigator.sendBeacon('/rum', JSON.stringify(m));
onLCP(send); onINP(send); onCLS(send); onFCP(send); onTTFB(send);
</script>
```
Track p75 (the metric used for ranking signals) over a 28-day window; alert when p75 crosses the threshold.
## Common Performance Mistakes
- Hero carousel: each slide is a hero candidate so LCP is unpredictable; replace with one strong hero or use IntersectionObserver to load slides only when active.
- Animated gradient backgrounds: continuous repaint; use a static gradient or a CSS-only animation that runs once.
- Background videos as decoration: heavy bytes, heavy CPU, distracting; replace with a still image or a short looping low-bitrate WebM.
- Massive sprite sheets: decoding cost; use individual SVGs or a font-icon system.
- Auto-playing audio: the browser blocks it and the script keeps trying.
- Overusing `requestAnimationFrame`: each rAF runs every frame; cancel when off-screen.
- Long-running `setInterval`: eats battery; replace with on-demand updates.
- Synchronous storage reads in the render path: `localStorage.getItem` is sync and blocking; cache to a variable.
## See Also
- [lighthouse.md](lighthouse.md) for score-driven audit fixes
- [audit-workflow.md](audit-workflow.md) for the capture-and-evidence loop
- [responsive.md](responsive.md) for layout integrity
- [motion.md](motion.md) for animation costs
references/quick-reference.md
---
title: Quick Reference
purpose: The condensed view of the highest-leverage rules across performance, accessibility, SEO, UI/UX, design, and multi-page consistency. Scan against any change.
load-when:
task-keywords: [rule, quick reference, highest leverage, checklist, axe, srcset, inline SVG, motion, phantom failure, lighthouse]
symptoms: [score dropped, contrast fail, duplicate id, image too small on retina, phantom dev failure]
prereq: SKILL.md
related: [pre-launch.md, lighthouse.md, performance.md, accessibility.md]
size: ~90 lines
---
# Quick Reference
A condensed view. Each rule is one dense line: principle, concrete check, threshold. Every rule has a longer treatment in the references.
## Performance (the levers that actually move the score)
- One LCP image, optimized end-to-end: serve AVIF first with WebP fallback, responsive srcset with at least 3 widths, declared width/height, fetchpriority="high", no lazy-load, preload only if the image is rendered by client JS.
- srcset must cover CSS width times device pixel ratio (a 360px box at 2x needs a 720w candidate); for an LCP or hero image emit up to about 2x the maximum layout width. Audit: image-size-responsive, uses-responsive-images.
- Inline critical CSS for above-the-fold and defer the rest: aim for under 14 KB of inline CSS so it fits in the first TCP round trip.
- Self-host fonts, subset to used glyphs, font-display: swap for body and optional for display, preload at most one critical font weight (never 4 weights).
- Cap initial JS at the budget: code-split by route and by interaction, defer hydration of below-the-fold islands, prefer server components or static HTML where possible.
- No layout-shifting late content: reserve space with aspect-ratio, min-height, or skeletons. The CLS budget is < 0.1 mobile / < 0.05 desktop.
- Animate only transform and opacity: anything that triggers layout (width/height/top/left/margin) is forbidden in animations.
- Pin third-party scripts to async/defer with fetchpriority="low", treat each as a Lighthouse hostage, and audit count and total CPU monthly.
- Cache aggressively at the edge: static assets get immutable + 1 year, HTML gets stale-while-revalidate, API responses get the longest TTL the data allows.
- Unique ids in reused components: a hardcoded DOM id produces duplicate ids when rendered more than once (invalid HTML, broken ARIA), so derive a per-instance id (useId() or equivalent) and namespace child ids.
- After ANY change to how CSS is delivered (inlining, bundling, chunk or concat order, a build flag), re-run axe on ALL routes, not just the changed page: an equal-specificity cascade reorder can flip a computed color with zero authored-rule change.
- Verify the shipped artifact: check copied static files and headers (a _headers file, robots.txt, security headers) in the BUILT output and the live response, not just source. Local preview is not the edge (it lacks the host response headers and is not the artifact users hit), so the live or field number outranks any local run, and you score a production build served by the framework preview or the live edge, never the dev server (a dev server manufactures phantom failures: an injected dev-toolbar link, a hot-reload socket that fails BFCache, unminified assets).
## Accessibility (the levers that actually move the score AND help real users)
- Semantic HTML first, ARIA only where semantics fall short: a <button> beats <div role="button"> every time.
- Contrast >= 4.5:1 for body, >= 3:1 for large text and meaningful UI graphics, verified in both light and dark mode separately.
- Visible focus on every interactive element with a 2-4px outline and 3:1 contrast against the surface and against the element's resting state.
- One H1, sequential headings, no skipped levels, no headings used as styling hooks.
- Every input has a programmatic label: a placeholder is not a label.
- Every meaningful image has descriptive alt; every decorative image has alt="".
- Keyboard parity: everything reachable by mouse must be reachable, operable, and visible-when-focused by keyboard.
- Respect prefers-reduced-motion by removing or shortening non-essential animation, never by leaving full motion in place.
- Inline svg is either aria-hidden="true" (decorative) or role="img" with a title or aria-label (meaningful). Never role="img" without a name (it trips image-alt).
## SEO (the levers that actually move the score AND drive traffic)
- Unique, intent-matching <title> and <meta name="description"> per page: 50-60 char title, 140-160 char description.
- Self-referencing canonical on every indexable page, with no conflicting canonicals between hreflang variants.
- One H1 with the primary intent term used naturally, plus sequential H2/H3.
- Descriptive, kebab-case, lowercase URLs without tracking params in canonical.
- JSON-LD structured data where applicable (Organization, WebSite, BreadcrumbList, Article, FAQPage, Product, SoftwareApplication, HowTo).
- Internal linking with descriptive anchor text: no "click here", no orphan pages.
- robots: index, follow on indexable pages, noindex on private, search-result, or duplicate pages.
- XML sitemap lists only canonical, indexable, 200-status URLs, referenced from robots.txt.
- AI answer-engine readiness: ship an llms.txt page index (and optionally an llms-full.txt of quotable facts), ensure AI crawler user-agents are not blocked in robots.txt (a dedicated named group is needed only when it repeats your disallows, since it replaces the * group), and keep load-bearing facts in server-rendered text because generative search reads and cites the rendered HTML and valid structured data, not images or client-only JS.
- Never fabricate structured data: emit aggregateRating, review, sameAs, and SearchAction only when each is backed by something real (real reviews, real profiles, a working search endpoint), and every JSON-LD value must be derivable from visible content.
## UI/UX (the levers that move perceived quality)
- Touch targets >= 44x44 CSS px for standalone controls (the iOS 44pt / Android 48dp guideline) with 8px+ spacing.
- Inline text links (breadcrumbs, in-prose links, footer text lists) are exempt under WCAG 2.5.8: do not inflate them to 44px (it reads as broken), and reserve the large target for buttons, toggles, icon buttons, and CTAs.
- Every async action has loading -> success/error feedback within 100ms of the trigger; skeleton screens beat spinners after 300ms.
- One primary CTA per screen, with secondary actions visually subordinate and destructive actions visually separated.
- State the empty state: empty lists, empty searches, and zero-data charts get a specific message, not a blank canvas.
- The mobile nav must work without JavaScript: if the small-screen nav is JS-driven, ship a <noscript> fallback nav or render the links in static HTML and enhance, so the links exist before and without hydration.
- Scroll-lock without a sideways jump: when locking body scroll for a modal, reserve the scrollbar width as padding-right (window.innerWidth - document.documentElement.clientWidth) and restore it on close, or the page shifts when the scrollbar disappears.
## Motion (the levers that keep motion from costing the score)
- Above-the-fold or hero motion ships only past a Lighthouse budget gate on the LCP page (LCP, TBT, CLS still in budget), otherwise make it static or cut it. Visual sign-off is not performance sign-off.
## Design (the levers that move "this looks designed")
- Pick a distinctive type pairing: refuse Inter + system-ui defaults, pair a characterful display face with a refined body face, cap to 2 families and 4 weights.
- Commit to a dominant color with one or two sharp accents (timid, evenly-distributed palettes read as generic), use semantic tokens, never raw hex in components.
## Multi-page consistency (the levers that prevent cross-page drift)
- Extract repeated markup at the third instance (or at the second with clear future reuse, or at any visible drift): one source of truth or one named variant, no third option.
- No page-local CSS overrides on shared widgets: components own their visual contract, pages adjust only what the component exposes.
- Capture before-and-after screenshots at 1440x900 and 375x812 for every visible change: vague claims of improvement without screenshot evidence are not evidence.
- Re-render every route after every fix group on shared widgets: a global change verified on one page is not verified.
- Geometry sweep returns zero issues at both audit viewports (viewport bleed, text overflow, sub-44 targets, duplicate arrows, drawer scroll-lock, focus presence). See [defects.md](defects.md).
## See also
- [pre-launch.md](pre-launch.md) for the verification gate to score these rules against
- [lighthouse.md](lighthouse.md) for the Lighthouse audit map underpinning these rules
- [performance.md](performance.md), [accessibility.md](accessibility.md) for the depth behind each rule
references/pwa-offline.md
---
title: PWA and Offline
purpose: Decide when a Progressive Web App earns its engineering cost, then implement service workers, offline shells, install prompts, push, and background sync without stranding users on a stale build.
load-when:
task-keywords: [PWA, offline, service worker, SW, install prompt, push, background sync, manifest, render strategy, checklist]
symptoms: [score dropped, slow page, third-party script slow]
prereq: SKILL.md
related: [performance.md, observability.md, pre-launch.md, security.md]
size: ~381 lines
---
# PWA and Offline
A PWA is a contract: the surface stays useful when the network does not, updates roll out without breaking the open tab, and the device treats the site like an app. Pay this cost only when the surface earns it.
## When to Invest in PWA
Ship if at least one rule holds; treat PWA as a first-class capability with its own pre-launch gate (see pre-launch.md) when two of three hold. If none apply, ship a fast website.
| Rule | Concrete check |
| --- | --- |
| Cross-session reuse | Users return to the same surface within seven days more than 30 percent of the time. A convert-once landing page does not pay back a service worker. |
| Install desire | Users would benefit from a launcher icon and a non-tab window (kiosk surfaces, internal tools, daily-use consumer apps, productivity tools). If they would never install it, do not ship `manifest.json`. |
| Offline-essential surface | Some workflow must keep working without connectivity (reading saved content, drafting a message, viewing already-loaded data, recording a measurement). If everything requires the server, offline support is theatre. |
- Anti-pattern: turning a static marketing site into a PWA because the audit gives points for it. The service-worker maintenance, kill-switch, install-prompt UX, and push-etiquette cost outweighs the benefit.
## Service Worker Lifecycle
A service worker is a separate JavaScript context with its own lifecycle, not a page script. Understand it or strand active tabs on a stale build.
| Event | Fires | Use it to |
| --- | --- | --- |
| `install` | Once per worker version, on first sight of a new file (worker not yet controlling pages) | Precache the app shell and version-pinned assets |
| `activate` | After `install` succeeds and any old worker stops controlling clients (worker now eligible to control pages) | Clean up old caches |
| `fetch` | Every network request from a controlled page | Decide cache, network, or both |
- Default lifecycle: a new worker waits until every controlled page closes before activating. This protects open tabs but leaves long-lived tabs on an outdated cache until closed.
- On the page, prompt the user before reloading for a new worker so unsaved input is not lost.
```javascript
// In the service worker
self.addEventListener('install', (event) => {
self.skipWaiting();
event.waitUntil(
caches.open('shell-v3').then((cache) => cache.addAll(SHELL_URLS))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
Promise.all([
caches.keys().then((keys) => Promise.all(
keys.filter((k) => k !== 'shell-v3' && k !== 'runtime-v1')
.map((k) => caches.delete(k))
)),
self.clients.claim()
])
);
});
```
```javascript
// On the page: prompt before reload, never lose guarded input
navigator.serviceWorker.register('/sw.js').then((reg) => {
reg.addEventListener('updatefound', () => {
const next = reg.installing;
next.addEventListener('statechange', () => {
if (next.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateBanner(() => next.postMessage({ type: 'SKIP_WAITING' }));
}
});
});
});
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
```
- Verify: open the surface in two tabs, deploy a new worker, observe one banner per tab, accept on one, confirm the other tab reloads cleanly without losing guarded user input.
## Offline Shell Pattern
The app shell is the minimum HTML, CSS, and JavaScript needed to render the application chrome (top bar, navigation, empty content slot) without any server data. Cache it at `install` so the surface paints offline on first navigation.
Precache:
- Shell HTML (a single document or a per-route entry document).
- Critical CSS bundled with the shell.
- Application logo and any chrome icons.
- A fallback offline page for routes that need network data.
- A small set of recently used dynamic assets if the surface depends on them.
- Hard cap the shell at 500 KB compressed; anything heavier means the user pays the cost on every install and update.
Runtime `stale-while-revalidate` for assets that tolerate a one-version-stale read:
```javascript
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.destination === 'image' || request.destination === 'style') {
event.respondWith(staleWhileRevalidate(request));
}
});
async function staleWhileRevalidate(request) {
const cache = await caches.open('runtime-v1');
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
}).catch(() => cached);
return cached || fetchPromise;
}
```
Navigation Preload: worker startup can add 100 to 400 ms before a navigation fetch begins. Preload starts the fetch in parallel with worker startup.
```javascript
self.addEventListener('activate', (event) => {
event.waitUntil(
self.registration.navigationPreload &&
self.registration.navigationPreload.enable()
);
});
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith((async () => {
const preloaded = await event.preloadResponse;
if (preloaded) return preloaded;
return fetch(event.request).catch(() => caches.match('/offline.html'));
})());
}
});
```
- Verify: cold-start on a throttled connection, measure first navigation, compare with and without preload enabled.
## Caching Strategies Cookbook
Pick one named strategy per resource class, never per request inside a class.
| Strategy | Behaviour | Best for |
| --- | --- | --- |
| Cache-first | Return cache if present, else fetch and cache | Immutable, versioned assets (`/assets/main.abc123.js`, fonts, icons) |
| Network-first | Try network, fall back to cache | HTML and per-request JSON where freshness beats offline |
| Stale-while-revalidate | Return cache immediately, refresh in background | Non-critical images, theme assets, infrequently-updated JSON |
| Network-only | Always fetch, never cache | Analytics beacons, authentication endpoints, anywhere stale answers are dangerous |
| Cache-only | Always return cache | Shell-precached resources where the build pipeline guarantees freshness on deploy |
```javascript
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
const cache = await caches.open('immutable-v1');
cache.put(request, response.clone());
return response;
}
async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open('runtime-v1');
cache.put(request, response.clone());
return response;
} catch (err) {
const cached = await caches.match(request);
return cached || caches.match('/offline.html');
}
}
```
Decision matrix:
| Resource class | Strategy |
| --- | --- |
| Versioned static (`abc123.js`, `abc123.css`) | Cache-first |
| Shell HTML | Cache-first with periodic background revalidation |
| Per-request HTML | Network-first with offline fallback |
| Read-mostly API JSON | Stale-while-revalidate |
| Write API (POST, PUT, DELETE) | Network-only (queue for Background Sync) |
| User avatars, thumbnails | Stale-while-revalidate |
| Analytics, telemetry | Network-only |
| Auth tokens, session endpoints | Network-only |
## Manifest Correctness for Installability
The browser installs the site when the user opens it on a supported platform, has engaged, and the manifest passes every required field. Miss one field, no install prompt.
| Field | Concrete check |
| --- | --- |
| `name` | Full application name, 45 characters or fewer. |
| `short_name` | Launcher label, 12 characters or fewer; longer truncates. |
| `icons` | At least one 192x192 PNG and one 512x512 PNG. The 512 must be square and look correct after the platform mask (maskable on some platforms, rounded corner on others). |
| `start_url` | Where the launcher opens. Absolute path (`/?source=pwa`) so tracking distinguishes installed traffic. |
| `display` | One of `standalone`, `fullscreen`, `minimal-ui`. `standalone` for most apps; `fullscreen` only for games or kiosks. |
| `theme_color` | Matches the top window-chrome color the device renders. |
| `background_color` | Shown while the app shell loads (the splash screen). |
| `id` | Stable identifier so the browser does not treat a `start_url` change as a new application. Set once and never change it. |
```json
{
"name": "Product Name",
"short_name": "Product",
"id": "/",
"start_url": "/?source=pwa",
"display": "standalone",
"theme_color": "#1a1a1a",
"background_color": "#fafafa",
"icons": [
{ "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
```
- Verify: open the browser DevTools Application panel, Manifest view. Every error and warning is a defect.
## Install Prompt Timing
The install event fires when the browser decides the site is installable. Capture it, suppress the default, and surface an affordance only after the user has clearly engaged.
```javascript
let deferredPrompt = null;
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault();
deferredPrompt = event;
document.querySelector('#install-button').hidden = false;
});
document.querySelector('#install-button').addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const choice = await deferredPrompt.userChoice;
deferredPrompt = null;
document.querySelector('#install-button').hidden = true;
});
```
- Value-demonstration rule: show the affordance only after a moment of value (completed a workflow, saved an item, returned for a second session). The first-load install banner is a defect.
- Never block content: the affordance is a small button or dismissible chip, never a modal, takeover, or blocking overlay. Dismiss once means hidden for the rest of the session.
- Verify: open as a new user, perform the first meaningful action, observe the affordance appear afterward, dismiss it, navigate, confirm it stays dismissed.
## Push Permission Timing
The push permission prompt is a one-shot grant. If denied, the surface cannot ask again without the user manually re-enabling it in browser settings. Treat the prompt as precious.
- Grant rate by entry point: first-load prompts grant under 5 percent; in-context prompts grant 40 to 70 percent.
- Never on first load: the user has no value signal, so the safe answer is no.
- Ask after a clear value action that obviously benefits from notifications (subscribes to a thread, saves a search, sets a reminder), with the enable affordance in the follow-up.
- In-app preview: before calling `Notification.requestPermission()`, show an in-app modal previewing exactly what the notifications look like and what triggers them. Only call the real permission API once the user confirms the in-app modal.
```javascript
async function offerPush() {
const inAppConfirmed = await showPushPreviewModal();
if (!inAppConfirmed) return;
const permission = await Notification.requestPermission();
if (permission === 'granted') await subscribeToPush();
}
```
- Verify: run a permission-grant funnel in analytics. The in-app preview should lift grant rate by 3x to 10x relative to a cold prompt.
## Background Sync
Background Sync queues a request when connectivity drops and replays it when the network returns. Best for write operations that must eventually succeed (sending a message, posting a comment, recording a measurement).
```javascript
// On the page
async function sendWithRetry(payload) {
try {
await fetch('/api/messages', { method: 'POST', body: JSON.stringify(payload) });
} catch (err) {
await saveToIDB('outbox', payload);
const reg = await navigator.serviceWorker.ready;
await reg.sync.register('flush-outbox');
}
}
// In the service worker
self.addEventListener('sync', (event) => {
if (event.tag === 'flush-outbox') {
event.waitUntil(flushOutbox());
}
});
```
- Support is uneven across engines (some Chromium-based engines ship it; others do not, as of 2026). For unsupported engines, fall back to a foreground retry queue with `online` event listening.
- Verify: load the surface, switch to airplane mode, perform a write, return online, confirm the write completes without user intervention.
## Service Worker Kill-Switch
A buggy worker can take a site down for every returning user until a new worker takes over. Ship the kill-switch on day one.
Deploy a static page at a known URL (`/unregister-sw`) that unregisters every registration and deletes every cache:
```html
<!DOCTYPE html>
<meta charset="utf-8">
<title>Reset Storage</title>
<script>
(async () => {
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
}
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
}
document.body.textContent = 'Done. You can close this tab.';
})();
</script>
```
- Serve the unregister page with the strongest reset header:
```text
Clear-Site-Data: "cache", "cookies", "storage"
```
- Respect `?nosw=1`: the active worker checks every navigation URL and unregisters itself, falling through to the network:
```javascript
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.searchParams.has('nosw')) {
self.registration.unregister();
return;
}
});
```
- Never cache the service worker file itself; serve `/sw.js` so a buggy worker does not stay alive after the fix lands:
```text
Cache-Control: no-cache, max-age=0, must-revalidate
```
- Verify: deploy a worker with a deliberate fault, confirm the unregister page recovers a stuck client, confirm `?nosw=1` recovers without manual cache clear.
## Web Share API
`navigator.share()` opens the platform share sheet so the user picks the target. Prefer it over a custom share menu when targets are known to the platform but unpredictable to your code.
```javascript
async function share(payload) {
if (!navigator.share) return fallbackShareMenu(payload);
try {
await navigator.share({
title: payload.title,
text: payload.summary,
url: payload.url
});
} catch (err) {
if (err.name !== 'AbortError') reportShareFailure(err);
}
}
```
- Prefer the platform sheet for any share button on touch platforms: it is faster, knows the user's frequent contacts, and respects accessibility settings.
- Keep a custom menu when sharing has side effects in your app (creates a server-side record, changes document permissions); then the menu is part of the workflow, not an OS handoff.
- Verify: tap the share affordance on a touch platform; the native sheet should open in under 200 ms.
## File System Access API
The File System Access API (`window.showOpenFilePicker`, `showSaveFilePicker`, `showDirectoryPicker`) gives durable handles to user-chosen files and folders without round-tripping through downloads.
- Available in Chromium-based browsers since 2020; other engines fall back to upload-and-download. Use it for editors that benefit from save-in-place, gate it behind feature detection, and ship a download fallback.
## See Also
- [performance.md](performance.md): caching headers, resource hints, and the bundle budgets that govern precache size.
- [observability.md](observability.md): instrumenting service-worker errors, push grant rates, and offline-render telemetry.
- [pre-launch.md](pre-launch.md): the PWA pre-launch gates (kill-switch deployed, manifest valid, install timing reviewed).
- [security.md](security.md): `Clear-Site-Data` headers, scope-isolation rules, and service-worker supply-chain considerations.
references/responsive.md
---
title: Responsive Layout
purpose: Framework-agnostic rules for layouts that hold across viewports, orientations, themes, zoom, and modern form factors. Covers breakpoints, container queries, fluid units, safe areas, subgrid, responsive images, scrollbar-gutter, and foldables.
load-when:
task-keywords: [responsive, breakpoint, mobile, container query, viewport, safe area, dvh, srcset, DPR, fluid typography, subgrid, scrollbar-gutter]
symptoms: [horizontal scroll, viewport overflow, scroll lock side shift, iOS 100vh, broken on Safari, rubber-band scroll, image too small on retina]
prereq: SKILL.md
related: [ui-ux.md, design.md, accessibility.md, performance.md]
size: ~481 lines
---
# Responsive Layout
Layouts that work from 320px phones to 4K displays, in portrait and landscape, light and dark, with system text scaling and browser zoom.
## Mobile-First
| Principle | Check |
| --- | --- |
| Design and code mobile first | Default styles target mobile; min-width media queries layer up. Mobile constraints force the right tradeoffs and most users are mobile. |
| Do not invert the cascade | Avoid max-width queries layered downward; they invert the natural flow. |
```css
/* Default styles target mobile */
.nav { padding: 12px; flex-direction: column; }
/* Min-width media queries layer up */
@media (min-width: 768px) {
.nav { padding: 16px 24px; flex-direction: row; }
}
```
### Mobile nav must survive without JavaScript
A JS-driven mobile menu has a trap: its links do not exist in static HTML until JS hydrates. With JS disabled, slow, or failed, a phone has a logo and nothing to navigate with.
| Check | Detail |
| --- | --- |
| Ship a `<noscript>` fallback nav | A plain `<nav>` of the same links, shown only on small screens, replaced when the JS menu hydrates. |
| Or render links in static HTML always | Let JavaScript progressively enhance them into a toggle. The LINKS must exist without JS, not the enhanced interaction. |
| Keep the perf-minded hydration directive | Load the menu JS only on small screens. |
| Reachability check | Load any page with JavaScript disabled at a phone width; every primary nav destination must be reachable. |
```html
<noscript>
<!-- shown only on small screens; the JS menu replaces it when hydrated -->
<nav aria-label="Primary" class="mobile-only">
<ul><li><a href="/a">A</a></li><li><a href="/b">B</a></li></ul>
</nav>
</noscript>
```
## Breakpoints
Pick a small, consistent set. Do not add a breakpoint per device; three or four well-chosen breakpoints handle every reasonable layout.
| Breakpoint | Pixel | Targets |
| --- | --- | --- |
| (default) | < 640px | Phones |
| `sm` | 640px | Large phones, small tablets |
| `md` | 768px | Tablets portrait |
| `lg` | 1024px | Tablets landscape, small laptops |
| `xl` | 1280px | Laptops, desktops |
| `2xl` | 1536px | Large desktops |
| `3xl` (optional) | 1920px | Very large displays |
## Canonical Audit Capture Viewports
Both sizes are required for any cross-page audit. See [audit-workflow.md](audit-workflow.md) Phase 4 for capture and [defects.md](defects.md) for the geometry sweep at both sizes.
| Role | Width x Height | Why |
| --- | --- | --- |
| Desktop audit | `1440x900` | Common laptop/external display proxy; wide enough for desktop nav, narrow enough to catch hero overflow. |
| Mobile audit | `375x812` | iPhone-class; exposes mobile drawer, drift, and touch-target failures. |
## Container Queries
| Feature | Principle + check |
| --- | --- |
| `@container` size queries | Use `container-type: inline-size` when the same component appears in different layouts so it responds to its container, not the viewport. Do NOT use for top-level page layout; viewport queries are simpler and more universally supported. |
| `@container style(--prop)` | Let a descendant respond to a container-scoped custom property (theme, density, brand variant) instead of nested theme-class selectors. |
| `@container scroll-state(stuck: top)` | Style a `position: sticky` element the moment it pins, replacing the JS `IntersectionObserver` sentinel (which remains the fallback). |
```css
.card { container-type: inline-size; container-name: card; }
@container card (min-width: 400px) {
.card-body { flex-direction: row; }
}
```
```css
.card { container-name: card; --theme: light; }
.card.dark { --theme: dark; }
@container card style(--theme: dark) {
.card-title { color: var(--color-foreground-on-dark); }
}
```
```css
.sticky-header {
position: sticky; top: 0;
container-type: scroll-state;
background: transparent;
transition: background 200ms ease-out;
}
@container scroll-state(stuck: top) {
.sticky-header { background: var(--color-surface); box-shadow: var(--shadow-sm); }
}
```
## :has() Cookbook
`:has()` (Baseline 2023, broad support across Chrome, Safari, Firefox) is the parent selector for parent-state styling.
| Pattern | CSS |
| --- | --- |
| Component variant by child presence | `.card { padding: var(--space-4); } .card:has(img) { padding: 0; }` |
| Form-level invalid state (no JS toggle) | `.form:has(:invalid) { border-color: var(--color-danger); } .form:has(:invalid) .submit-btn { opacity: 0.6; pointer-events: none; }` |
| Layout shift by child count | `.menu:has(> :nth-child(6)) { flex-direction: column; }` (vertical when more than five items) |
| Container-relative hover | `.card:has(button:hover) { transform: translateY(-2px); }` |
| Empty-state styling (no special class) | `.list:not(:has(li)) { display: grid; place-items: center; min-height: 200px; }` |
Rules:
- `:has()` is computed at every style recalculation; do not chain deep `:has(.a:has(.b:has(.c)))` expressions in heavy lists.
- Do not ship the `:has()` polyfill (JavaScript that re-runs on every DOM change); let unsupported browsers degrade to the unstyled state.
## Fluid Typography
| Check | Detail |
| --- | --- |
| Use `clamp(min, preferred, max)` | Scale type smoothly between breakpoints rather than stepping at fixed media queries. The preferred value reaches min at the smallest supported viewport and max at the largest. |
| Keep `line-height` unitless | e.g. `line-height: 1.2`, so fluid scaling happens automatically. |
```css
.h1 { font-size: clamp(2rem, 1.5rem + 2.5vw, 4rem); } /* min 32px, max 64px */
```
## Viewport Units
| Unit | Meaning |
| --- | --- |
| `vw` / `vh` | 1% of viewport width/height (legacy, includes browser chrome on mobile) |
| `dvw` / `dvh` | Dynamic viewport (changes as browser chrome shows/hides); use for full-height mobile layouts |
| `svw` / `svh` | Small viewport (smallest possible, browser chrome visible) |
| `lvw` / `lvh` | Large viewport (largest possible, browser chrome hidden) |
| `cqw` / `cqh` | Container query units |
For full-height mobile heroes use `min-height: 100dvh` (not `100vh`); `100vh` overflows on iOS Safari when the address bar is showing.
## Responsive Images
srcset candidates must cover CSS width times DPR. See [performance.md](performance.md) for image format and `fetchpriority`.
Arithmetic:
- Largest real demand = (max CSS width from the `sizes` attribute) times (max DPR you support, usually 2 or 3).
- Emit candidates up to that width.
- For an LCP/hero image, emit up to about 2x the maximum layout width.
Worked example (brand-free): a card image rendered in a 360px box with `sizes="(min-width: 1024px) 360px, 90vw"` needs a 720w candidate (2x of 360), so emit widths 400 and 720. Below that, `image-size-responsive` fires on retina phones.
```html
<img
src="card-720.jpg"
srcset="card-400.jpg 400w, card-720.jpg 720w"
sizes="(min-width: 1024px) 360px, 90vw"
alt="" />
```
## Safe Areas
Modern phones have notches, dynamic islands, gesture bars, home indicators. Respect them.
| Check | Detail |
| --- | --- |
| Pad fixed bars with safe-area insets | `padding-bottom: max(16px, env(safe-area-inset-bottom))` and `padding-top: max(16px, env(safe-area-inset-top))`. |
| Viewport meta needs `viewport-fit=cover` | Required for `env(safe-area-inset-*)` to work. |
```css
.fixed-bottom-bar { padding-bottom: max(16px, env(safe-area-inset-bottom)); }
.fixed-top-bar { padding-top: max(16px, env(safe-area-inset-top)); }
```
```html
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
```
## Logical Properties
Use logical properties for RTL support; when `<html dir="rtl">` they automatically flip while physical properties do not. See [i18n.md](i18n.md) for RTL mirroring.
| Physical | Logical |
| --- | --- |
| `margin-left` | `margin-inline-start` |
| `margin-right` | `margin-inline-end` |
| `padding-top` | `padding-block-start` |
| `padding-bottom` | `padding-block-end` |
| `text-align: left` | `text-align: start` |
| `text-align: right` | `text-align: end` |
| `border-left` | `border-inline-start` |
| `width` | `inline-size` |
| `height` | `block-size` |
## Layout Primitives
Build layouts from a small set of primitives.
| Primitive | Purpose + CSS |
| --- | --- |
| Stack | Vertical layout, consistent gap: `.stack { display: flex; flex-direction: column; gap: var(--space-4); }` |
| Cluster | Horizontal wrapping layout: `.cluster { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; }` |
| Center | `.center { box-sizing: content-box; max-inline-size: 65ch; margin-inline: auto; padding-inline: var(--space-4); }` |
| Frame | Fixed aspect ratio: `.frame { aspect-ratio: 16 / 9; overflow: hidden; }` with child `img`/`video` at `inline-size: 100%; block-size: 100%; object-fit: cover`. |
### Sidebar
Two columns, one fixed-width, the other flexes.
```css
.with-sidebar { display: flex; flex-wrap: wrap; gap: var(--space-6); }
.with-sidebar > .sidebar { flex-basis: 280px; flex-grow: 1; }
.with-sidebar > .main { flex-basis: 0; flex-grow: 999; min-inline-size: 50%; }
```
### Switcher
Multi-column that becomes a stack below a threshold; items stack when the container drops below 600px.
```css
.switcher { display: flex; flex-wrap: wrap; gap: var(--space-4); }
.switcher > * { flex-grow: 1; flex-basis: calc((600px - 100%) * 999); }
```
### Cover
Fill available space with header, centered content, footer.
```css
.cover { display: flex; flex-direction: column; min-block-size: 100dvh; padding: var(--space-4); }
.cover > main { margin-block: auto; }
```
### Reel
Horizontal scrolling cluster (carousel without arrows).
```css
.reel {
display: flex; gap: var(--space-4);
overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: thin;
}
.reel > * { flex: 0 0 auto; scroll-snap-align: start; }
```
### Grid (auto-fit)
Wraps 1 to 2/3/4 columns as the container grows.
```css
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-4);
}
```
### Subgrid
Subgrid (Baseline 2024, Chrome, Safari, Firefox) aligns a nested element to the parent grid's rows or columns. Titles, bodies, and footers align across siblings automatically because they share tracks.
```css
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
grid-template-rows: auto 1fr auto; /* title, body, footer */
gap: var(--space-4);
}
.card { display: grid; grid-template-rows: subgrid; grid-row: span 3; }
.card-title { grid-row: 1; }
.card-body { grid-row: 2; }
.card-footer { grid-row: 3; }
```
| Use subgrid for | Anti-pattern |
| --- | --- |
| Card grids where titles, prices, CTAs align across rows with varying body lengths | Using subgrid on a grid that does not need named-area alignment. If children need not align to the parent's tracks, regular grid is simpler. |
| Forms where labels, inputs, helper text form three clean columns across nested fieldsets | |
| Editorial pages where a sidebar caption column aligns to the body grid | |
## Containers and Max Widths
Do not go full-width on huge displays; lines longer than ~75 characters become hard to read. For prose, set `max-inline-size: 65ch`.
```css
.container {
max-inline-size: var(--container-width, 1280px);
margin-inline: auto;
padding-inline: clamp(16px, 4vw, 48px);
}
```
| Max-width | Use |
| --- | --- |
| 640px | Long-form reading content |
| 768px | Reading with a sidebar |
| 1024px | Routine application UI |
| 1280px | Hero or feature-rich layouts |
| 1440px | Big imagery, generous whitespace |
| 1600px | Wide screens, data-heavy surfaces |
## Animating Intrinsic Sizes
`interpolate-size: allow-keywords` (Baseline 2026, Chrome shipping) lets the browser interpolate intrinsic-size keywords (`auto`, `min-content`, `max-content`), replacing the JS pre-measure pattern for `height: auto` animations. `calc-size()` extends it for arithmetic: `height: calc-size(auto, size + 16px)` animates to natural height plus padding. Older browsers without `interpolate-size` snap open instantly (clean degradation).
```css
:root { interpolate-size: allow-keywords; }
.accordion-body { height: 0; overflow: hidden; transition: height 250ms ease-out; }
.accordion-body[data-open] { height: auto; }
@media (prefers-reduced-motion: reduce) {
.accordion-body { transition: none; }
}
```
## Scrollbars
`scrollbar-gutter: stable` (Baseline 2024) reserves scrollbar space so removing the scrollbar (scroll lock, content shrink, fading overlay scrollbar) no longer reflows the page. This fixes the page-jump-sideways modal-shift bug without JS scrollbar-width padding. On overlay-scrollbar systems (macOS default, mobile) the gutter is 0px and nothing changes. Use `stable both-edges` to reserve the gutter on both sides for symmetry on a centered layout.
```css
:root { scrollbar-gutter: stable; }
```
## Horizontal Scroll: Forbidden
The single most common responsive bug. Causes: fixed-pixel widths exceeding viewport, long unbreakable text (URLs, code, CJK), images without `max-width: 100%`, tables without overflow, sidebars that do not collapse.
| Fix | Detail |
| --- | --- |
| `max-inline-size: 100%` on images/embeds | Or `max-width: 100%` for non-RTL contexts. |
| `overflow-wrap: break-word` + `word-break: break-word` | For long unbreakable strings. |
| Wrap wide tables in `<div style="overflow-x: auto">` | Handles table overflow. |
| Collapse sidebars at small breakpoints | |
| Test at 320px width | Scroll horizontally; if you can, fix it. |
## Accessibility at Different Viewports
| Standard | Requirement |
| --- | --- |
| WCAG 1.4.10 (Reflow) | Content usable at 320 CSS pixels wide without horizontal scroll. |
| WCAG 1.4.4 (Resize Text) | Content usable at 200% browser zoom without loss of functionality. |
Test by resizing the browser to 320px wide, setting browser zoom to 200%, and setting OS text size to largest; fix if the layout breaks. See [accessibility.md](accessibility.md) for accessible scaling.
## Orientation
| Check | Detail |
| --- | --- |
| Do not lock layout to portrait | Some users hold phones in landscape. |
| Landscape phones are short | Do not assume they have a tablet-class screen height. |
| Support both orientations for full-screen forms/inputs | Test landscape mode on an actual phone. |
### Foldables and device-posture
`viewport-fit=cover` on the viewport meta is required so layout can reach the seam and safe-area insets work. The `device-posture` media query and the `screen.fold` API (where supported) let layout adapt to the hinge. For most surfaces, foldable support is one breakpoint test (does the layout look right at 600 to 800px unfolded?) plus a viewport meta; treat `device-posture` as progressive enhancement (partial support).
```css
@media (device-posture: folded) {
.reader { columns: 1; padding-inline: var(--space-4); }
}
@media (device-posture: continuous) {
.reader { columns: 2; column-gap: var(--space-8); }
}
```
## Dynamic Type / Font Scaling
Use `rem` for font-size (body `1rem` defaults to 16px) so user agent text scaling and browser zoom work. Avoid `px` for font sizes except logos and single-line UI labels.
```css
body { font-size: 1rem; } /* defaults to 16px, scales with user prefs */
h1 { font-size: 2.5rem; }
```
## Media Preferences
| Preference | CSS |
| --- | --- |
| Reduced motion | Respect `prefers-reduced-motion`. See [motion.md](motion.md). |
| Reduced data | `@media (prefers-reduced-data: reduce) {}` to skip non-essential animations, replace videos with posters, lower image quality. |
| High contrast | `@media (prefers-contrast: more) { :root { --color-border: var(--color-foreground); } }` to bump border weights and increase contrast. |
### Color scheme
```css
@media (prefers-color-scheme: dark) {
:root { /* dark tokens */ }
}
/* or a [data-theme="dark"] selector for a user-controlled toggle */
/* Opt in to native UA dark mode controls (scrollbars, form controls) */
:root { color-scheme: light dark; }
[data-theme="light"] { color-scheme: light; }
[data-theme="dark"] { color-scheme: dark; }
```
## Print
Content-rich surfaces may be printed. Add a minimal print stylesheet. See [print-email.md](print-email.md) for full print/email treatment.
```css
@media print {
nav, footer, .no-print { display: none; }
body { font-size: 12pt; color: black; background: white; }
a { color: black; text-decoration: underline; }
a[href]::after { content: " (" attr(href) ")"; }
}
```
## Touch and Pointer
| Pointer type | Meaning |
| --- | --- |
| `fine` | mouse, trackpad, stylus |
| `coarse` | touch |
| `hover` capability | device can hover (hover-on-hold for touch is not real hover) |
```css
/* Hover-capable pointer */
@media (hover: hover) and (pointer: fine) {
.card:hover { transform: translateY(-2px); }
}
/* Touch-only: larger hit targets, no hover-only affordances */
@media (hover: none) and (pointer: coarse) {
.icon-btn { padding: 12px; }
}
```
## Common Responsive Mistakes
- One desktop-first stylesheet with `max-width` overrides that pile up.
- Fixed-pixel widths (`width: 1200px`) on containers.
- Images without `max-width: 100%`.
- Tables without horizontal scroll.
- Sidebars that do not collapse.
- Hero text readable on desktop but a wall of words on mobile.
- Buttons that are tiny on mobile.
- Inputs that auto-zoom on iOS because text size is below 16px.
- A `100vh` hero that overflows on iOS Safari.
- A surface that scrolls 12 screens on mobile while only 2 screens on desktop.
- Padding too tight (cramping) or too generous (wasting space) on mobile.
- Font too big (5 words per line) or too small (10pt body) on mobile.
## Self-Healing for Responsive
Before declaring work complete:
- [ ] Tested at 320px, 375px, 768px, 1024px, 1280px, 1920px
- [ ] Tested in portrait and landscape on a phone
- [ ] No horizontal scroll at any width
- [ ] Body text >= 16px on mobile
- [ ] Touch targets >= 44x44 with 8px gaps
- [ ] All images, videos, iframes have max-width: 100%
- [ ] Long tables have horizontal scroll
- [ ] Sidebar collapses on small screens
- [ ] Modals fit smallest viewport
- [ ] 200% browser zoom: layout intact
- [ ] System text size at largest: layout intact
- [ ] Safe area respected on notched/island devices
- [ ] `100dvh` instead of `100vh` for full-height mobile
- [ ] Works at `prefers-reduced-motion: reduce`
- [ ] Works at `prefers-color-scheme: dark`
- [ ] Tested with keyboard only
## See Also
- [ui-ux.md](ui-ux.md) for the functional spacing/layout rules
- [design.md](design.md) for visual rhythm and grids
- [accessibility.md](accessibility.md) for accessible scaling
- [performance.md](performance.md) for responsive image loading and `fetchpriority`
references/security.md
---
title: Security Beyond CSP
purpose: Frontend security headers, isolation primitives, supply-chain hygiene, and secrets discipline at depth. Covers CSP, Trusted Types, SRI, COOP, COEP, CORP, Permissions-Policy, Referrer-Policy, frame-ancestors, and dependency audits.
load-when:
task-keywords: [security, CSP, COOP, COEP, CORP, cross-origin isolation, SRI, Trusted Types, Permissions-Policy, Referrer-Policy, frame-ancestors, dependency]
symptoms: [score dropped, third-party script slow, broken on Safari, broken on Firefox, stale SRI beacon]
prereq: SKILL.md
related: [lighthouse.md, build-hygiene.md, embed-patterns.md, pre-launch.md]
size: ~321 lines
---
# Security Beyond CSP
The Best Practices audit flags the obvious holes (mixed content, missing CSP, broken HTTPS). The real surface is wider: the headers, policies, and process disciplines that keep a frontend defensible after the audit passes. Every rule below is a principle plus a concrete check at the standard level (CSP directive, HTTP header, browser API), not at the library level.
## Threat Model Overview
Five threat categories cover almost every real frontend incident.
| Threat | Mechanism | Mitigation |
| --- | --- | --- |
| DOM-based XSS | Untrusted strings reach a script-executing sink | Trusted Types or a sanitiser at every sink |
| MITM | Network attacker injects/observes/modifies traffic | HSTS, HTTPS everywhere, upgrade-insecure-requests |
| Clickjacking | Third party frames your origin to harvest clicks | frame-ancestors, SameSite cookies |
| Supply chain | A trusted dependency/build tool/CDN ships malice | Integrity pinning, lockfile discipline, provenance |
| Secrets exfiltration | A secret lands in client code, storage, or a URL | Keep secrets server-side, scan, rotate |
DOM-XSS dangerous sinks where untrusted strings must not flow: `innerHTML`, `eval`, `document.write`, `setAttribute('on*', ...)`, `<script>.src`.
Checks:
- DOM-XSS: every dynamic write to a script-executing sink (`innerHTML`, `outerHTML`, `document.write`, `eval`, `new Function`) is wrapped in a Trusted Types policy or sanitiser. Audit: `grep -rnE 'innerHTML\s*=|outerHTML\s*=|document\.write\(|eval\(|new Function\(' src/` returns a known, audited list.
- MITM: mitigations are HSTS, HTTPS everywhere (no mixed content), and `upgrade-insecure-requests` in CSP.
- HSTS: `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload` is present on every HTML response, and the site is on the HSTS preload list (hstspreload.org).
- Clickjacking: `Content-Security-Policy: frame-ancestors 'none'` (or an explicit allow-list) is present. `X-Frame-Options` is allowed as a redundant belt-and-braces header but is superseded by `frame-ancestors`.
- Supply chain: every third-party CDN script has an `integrity="sha384-..."` attribute or is self-hosted; `npm ci --ignore-scripts` succeeds in CI; `npm audit --omit=dev` returns 0 high or critical findings, or each has a tracked waiver.
- Secrets: `grep -rIE 'sk_live|sk_test|AKIA|ghp_|xox[abp]-' dist/` returns nothing; production source maps are uploaded privately and never published on the public CDN (see observability.md).
## CSP at Depth
Content-Security-Policy is the highest-leverage frontend security header and the easiest to deploy badly. Three flavours, in order of preference.
### Build-time script hashes (static hosts)
When the build outputs a fixed set of inline or external scripts, compute their SHA-256 hashes at build time and emit them as `'sha256-<base64>'` source expressions in `script-src`/`style-src`. No per-request work, cacheable header, no nonce-rotation cost.
```text
Content-Security-Policy: default-src 'self';
script-src 'self' 'sha256-AbC123...' 'sha256-DeF456...';
style-src 'self' 'sha256-GhI789...';
img-src 'self' data: https://images.cdn.example;
font-src 'self' data:;
connect-src 'self' https://api.site.example;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
upgrade-insecure-requests;
```
Check: every `<script>` tag the browser executes resolves to one of the hashes in the CSP, with zero CSP violations in the DevTools Console after a deploy.
### Nonces (dynamic hosts)
When the server renders HTML per request and inline scripts vary, mint a fresh cryptographic nonce per request and reflect it on each `<script nonce="...">` plus the CSP header. `'strict-dynamic'` grants trust transitively to scripts loaded by an already-nonced script, so you need not enumerate every dependency.
```text
Content-Security-Policy: default-src 'self';
script-src 'self' 'nonce-Rk9yLW5vbi1jb29sLW5vbmNlcw==' 'strict-dynamic';
...
```
Check: the nonce is generated per request from a cryptographic RNG (`crypto.randomBytes(16).toString('base64')`, not `Math.random()`), is at least 128 bits, and is never reused.
### `'unsafe-inline'` as documented fallback
If a third party demands inline event handlers and refuses nonces, document the exemption in a security note, narrow to `script-src-elem` (not `script-src-attr`), and plan removal.
Check: every `'unsafe-inline'` in production CSP has a tracked issue and an owner. The Lighthouse `csp-xss` audit (see lighthouse.md) red badge is acceptable only with a written waiver.
### Report-only rollout
Deploy a new CSP with `Content-Security-Policy-Report-Only:` for two weeks, collect violations via the Reporting API (see observability.md), iterate, and promote to enforcing only when the violation stream is clean.
## Trusted Types
CSP blocks loading malicious scripts; Trusted Types blocks dynamic creation of new injection sinks. They compose.
### The directive
```text
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default dompurify;
```
Enforcing this makes every assignment to `Element.innerHTML`, `HTMLScriptElement.src`, `eval()`, `setTimeout(string)` throw a TypeError unless the value is a `TrustedHTML`, `TrustedScript`, or `TrustedScriptURL` minted by a named policy.
### The policy
Run early, before any code that touches `innerHTML`.
```js
if (window.trustedTypes && trustedTypes.createPolicy) {
trustedTypes.createPolicy('default', {
createHTML: (input) => sanitize(input, { RETURN_TRUSTED_TYPE: true }),
createScript: () => { throw new Error('inline script forbidden'); },
createScriptURL: (input) => {
const url = new URL(input, location.origin);
if (url.origin !== location.origin) {
throw new Error('cross-origin script forbidden');
}
return url.toString();
},
});
}
```
Checks the policy enforces:
- Every HTML sink runs through the sanitiser as an audited list, no exceptions.
- Inline script creation is rejected outright; if needed, it gets a named policy with reviewed allow-rules.
- Script-URL loads are restricted to same-origin; cross-origin needs an explicit second policy and a reason.
### Browser support and rollout
Chromium and Edge fully support Trusted Types; Firefox and Safari evaluate via a polyfill (Web Components polyfill plus a `trusted-types` shim). Deploy report-only first to catch unintended sinks; promote after the violation stream is clean.
Check: `trustedTypes.defaultPolicy` is defined at page load, the CSP header includes `require-trusted-types-for 'script'`, and the Reporting API endpoint (see observability.md) receives zero violations for two weeks before promotion.
## Subresource Integrity (SRI)
When you load a script from a CDN you do not control, the CDN can swap the file. SRI pins the bytes.
```html
<script
src="https://cdn.vendor.example/widget-1.2.3.js"
integrity="sha384-abc123...xyz789"
crossorigin="anonymous"
></script>
```
Requirements and checks:
- The CDN must serve `Access-Control-Allow-Origin: *` (or an explicit origin) so the browser can read the body to verify the hash.
- `crossorigin="anonymous"` must be set to opt the script into CORS so the integrity check runs.
- Use SRI only when self-hosting is genuinely impossible (vendor demands the CDN URL for license enforcement, or the script is too large to redeploy per release); most scripts should be self-hosted, SRI is the fallback.
- Every off-origin `<script src="https://...">` has an `integrity` attribute, CI fails the build when a third-party script is added without one, and `grep -rnE '<script[^>]+src="https?://' dist/` returns only entries with `integrity=`.
- Refresh on vendor update: a pinned hash blocks vendor fixes. Track the vendor's release notes, recompute the hash on every version bump, and treat the integrity update like any other dependency upgrade (PR, review, deploy).
## COOP, COEP, CORP, and Cross-Origin Isolation
The isolation triad unlocks `SharedArrayBuffer`, high-precision `performance.now()`, and OffscreenCanvas in workers, and blocks a class of cross-origin side-channel attacks.
| Header | Value | Effect |
| --- | --- | --- |
| Cross-Origin-Opener-Policy | `same-origin` | Severs the link to any cross-origin opener so it cannot script your DOM through `window.opener`; required for isolation |
| Cross-Origin-Embedder-Policy | `require-corp` | Requires every cross-origin subresource (images, scripts, iframes) to opt in via CORP or CORS, stopping drive-by loading of non-consenting resources |
| Cross-Origin-Resource-Policy | `same-origin` (or `same-site`, `cross-origin`) | On a response, tells the browser which origins may embed it; send on every static asset you control |
All three must align to enable isolation:
```text
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
```
When set: `self.crossOriginIsolated === true`, `SharedArrayBuffer` becomes constructible, `performance.now()` returns microsecond precision, and OffscreenCanvas transfers cleanly to workers.
### Migration plan and checks
The triad breaks naive embeds: any iframe, image, font, or script from a third-party origin without CORP or CORS fails to load.
- Inventory every cross-origin resource: add CORP to the ones you control, CORS plus a `crossorigin` attribute to the ones you do not.
- Replace embeds you cannot fix with same-origin proxies.
- Deploy COOP and COEP report-only (`Cross-Origin-Opener-Policy-Report-Only`, `Cross-Origin-Embedder-Policy-Report-Only`) for two weeks.
- Promote to enforcing.
Check: `self.crossOriginIsolated` is `true` on every route needing `SharedArrayBuffer` or high-precision timers, and the Reporting API receives zero `coep` or `coop` violation reports for a full deploy cycle before flipping enforcing.
## Permissions-Policy
`Permissions-Policy` gates browser-feature access by origin: default-deny, then opt in.
```text
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(),
usb=(), serial=(), bluetooth=(), magnetometer=(), gyroscope=(), accelerometer=(),
fullscreen=(self), picture-in-picture=(self), autoplay=(self),
interest-cohort=()
```
Syntax: empty parentheses `()` mean no origin (including self) may use the feature; `(self)` means the top-level document can; `(self "https://trusted.example")` adds an allow-listed origin.
Iframe propagation is controlled by the `allow` attribute:
```html
<iframe src="https://embed.vendor.example" allow="camera 'none'; microphone 'none'"></iframe>
```
Checks:
- Every feature the site does not use is denied, so a future XSS calling `navigator.geolocation.getCurrentPosition` fails silently and a compromised iframe cannot ask for the camera.
- The top-level `Permissions-Policy` header denies every unused feature, and every iframe `allow` attribute is an explicit allow-list, not an empty default.
## Referrer-Policy
`Referrer-Policy` controls what the browser sends in the `Referer` header on outbound navigation and subresource loads.
```text
Referrer-Policy: strict-origin-when-cross-origin
```
This is the W3C default since 2020 and the right starting point for almost every site. Behaviour:
- Same-origin requests get the full URL (path and query).
- Cross-origin HTTPS requests get only the origin (scheme + host + port).
- HTTPS-to-HTTP requests get nothing.
Checks:
- Use `no-referrer` on sensitive pages (auth flows, reset-password) where even the origin is privacy-leaking, and `same-origin` for embedded admin tools that must never leak referrer externally.
- Response headers include `Referrer-Policy: strict-origin-when-cross-origin` (or stricter), and no page sets `unsafe-url` without a documented reason.
## frame-ancestors (Clickjacking)
`frame-ancestors` in CSP supersedes the older `X-Frame-Options` header: CSP-level, same delivery and enforcement model, more expressive.
Pages that should never be framed get `'none'`:
```text
Content-Security-Policy: frame-ancestors 'none';
```
`X-Frame-Options: DENY` may stay as a redundant header for ancient clients but adds no protection beyond `frame-ancestors 'none'`. Embeddable pages get an explicit allow-list:
```text
Content-Security-Policy: frame-ancestors 'self' https://partner.example https://*.site.example;
```
Check: top-level CSP includes `frame-ancestors` matching the site's framing intent (typically `'none'`), and pages designed to be embedded have a written allow-list reviewed quarterly.
## Third-Party Script Supply Chain
The biggest frontend security incidents of the last decade were supply chain. Tooling shifts the slope.
### Lockfile review
Review every lockfile diff like code:
- Newly added transitive dependencies (a small direct addition can pull in hundreds).
- Version-range widening (a `^` bump 1.2.0 to 1.9.0 is a different review than a `~` bump 1.2.0 to 1.2.3).
- Ownership changes (`npm view <pkg>` includes `maintainers`).
Check: PRs that modify `package.json` always modify the lockfile in the same commit, CI fails when only one changes, and reviewers read the lockfile diff not just the manifest.
### Dependency auditing
Run `npm audit --omit=dev` or `pnpm audit --prod` on every PR. Treat high and critical findings as blocking, track moderate findings with an owner and deadline, and triage waivers explicitly. Tooling beyond the built-in audit: a software-composition-analysis service, a dependency-update bot, or a runtime-behaviour analyzer (postinstall scripts, network calls). Pick one, run it, act on the output.
### Integrity-pinned releases
When shipping a frontend library to other teams, publish with provenance (`npm publish --provenance` since npm 9.5) so consumers can verify the package came from your CI workflow, not a stolen token.
Check: published packages show a green provenance badge and releases come from CI, not developer laptops.
### npm-token-theft mitigations
Stack three:
- 2FA on every publisher account (`npm access set 2fa=publish`).
- Granular per-package tokens with expiry, rotated quarterly.
- Integrity pinning in consumers via `package-lock.json` `integrity` hashes, with CI using `npm ci` (never `npm install`).
Check: CI uses `npm ci --ignore-scripts`, the lockfile is committed, publishing tokens have 90-day expiry, and provenance is on.
## Secrets in Frontend Code
Frontend code ships to every user. There are no secrets in it.
Anything in the JS bundle, the HTML, env-injected build-time variables, or runtime config is public, so treat it as published. Publishable keys (`pk_*`, public DSNs) are designed for client exposure; secret keys (`sk_*`) are not.
Checks:
- `grep -rIE 'sk_live|sk_test|AKIA|ghp_|xox[abp]-|password|passwd' dist/` returns nothing, and only `PUBLIC_*` or framework-prefixed public variables are embedded via a reviewed build-time env allowlist.
- When a secret ships: rotate immediately and leave git history (generate a new key, deploy, revoke the old within the hour) as the default; rewrite git history with a history-filtering tool only when the secret cannot be rotated. Rotate first either way.
- The leaked-secret incident playbook documents the rotation contact for every credential type, mean time to rotate is under one hour, and the postmortem captures the leak source and prevention (pre-commit hook, CI secret scanner).
- Run a secret scanner at the pre-commit hook and in CI on the full diff, block the commit and PR on a credential pattern hit, and maintain an allow-list for genuine false positives; repos with historical leaks are scanned and leaks tracked to closure.
## Dependency Cost at Audit Time
Security and performance overlap on dependencies: every transitive add is a new supply-chain entry and a new bundle line item (the bundle side lives in build-hygiene.md). Per major dependency, capture:
- Bytes: measured via a bundle-size service or local `size-limit`. Above 10 KB gzipped, the dependency needs justification.
- Maintenance: last release date, open-issue count, single-maintainer risk. A sole maintainer with no release in 18 months is a supply-chain risk.
- License: SPDX identifier, verified compatible with your distribution license.
- Alternatives: could a 30-line same-file utility replace it? Could the platform (`Intl.NumberFormat` instead of a number-formatting library, native `fetch` instead of an HTTP-client library)?
Check: dependency review is a recurring quarterly task whose output is a list of dependencies to remove, replace, or pin tighter, feeding the prelaunch evidence manifest (see pre-launch.md).
## Headers Quick Reference
Every production HTML response should include at least these eight headers. Tune the values; do not omit any without a written reason.
```text
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; ... ; frame-ancestors 'none'; ...
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), ...
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff
```
Check: `curl -sI https://site.example | grep -iE 'strict-transport|content-security|cross-origin|permissions-policy|referrer-policy|x-content-type'` lists all eight, each value matches the intended policy, and the pre-launch.md evidence manifest records the curl output as an artefact.
## See Also
- [lighthouse.md](lighthouse.md): Best Practices audit row mapping (csp-xss, valid-source-maps, no-vulnerable-libraries)
- [build-hygiene.md](build-hygiene.md): dependency cost discipline at bundle time
- [embed-patterns.md](embed-patterns.md): cross-origin isolation interactions with iframes
- [pre-launch.md](pre-launch.md): the headers gate in the evidence manifest
references/seo.md
---
title: SEO Playbook
purpose: On-page and technical SEO covering indexability, structured data, AI answer engines, hreflang, pagination signals, third-party-cookie deprecation impact, image and video sitemaps, and CrUX field mapping. Hits the Lighthouse SEO category and earns rankings.
load-when:
task-keywords: [SEO, indexing, canonical, sitemap, robots, structured data, JSON-LD, hreflang, Open Graph, meta description, title tag, AEO]
symptoms: [score dropped, canonical mismatch, noindex with sitemap]
prereq: SKILL.md
related: [lighthouse.md, performance.md, accessibility.md, observability.md]
size: ~561 lines
---
# SEO Playbook
On-page and technical SEO that hits the Lighthouse SEO category and earns rankings. The foundation for AI search optimization (AEO/GEO/LLMO) and programmatic SEO at scale.
## Priority Order
1. Crawlability and indexation. If a crawler cannot find or index it, nothing else matters.
2. Technical foundations. Speed, mobile, HTTPS, structure.
3. On-page optimization. Title, description, headings, content, internal links.
4. Content quality. E-E-A-T, depth, intent match.
5. Authority and links. Off-page; outside the scope of this skill.
## Indexability
### Robots.txt
| Check | Detail |
| --- | --- |
| Location and status | Lives at `/robots.txt` and returns a 200 status. |
| Do not block assets | Do not disallow `/_next/` or `/static/` (blocks asset crawl, hurts rendering). |
| Do not block the site | Do not accidentally disallow the entire site with `Disallow: /`. |
| Include sitemap line | Do not forget the `Sitemap:` line. |
```
User-agent: *
Allow: /
Disallow: /api/
Disallow: /private/
Sitemap: https://example.com/sitemap.xml
```
### XML Sitemap
- Location: `/sitemap.xml` (or `/sitemap-index.xml` for large sites with sub-sitemaps).
- Lists only: canonical URLs, 200-status URLs, indexable URLs (no `noindex`), and URLs the site actually owns (no third parties).
- For large sites, split into sub-sitemaps of 50,000 URLs each, referenced from `sitemap-index.xml`.
- `changefreq` and `priority` are advisory and mostly ignored; `lastmod` is honored when accurate.
- Submit the sitemap to the search console and verify it is accessible from `robots.txt`.
```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/</loc>
<lastmod>2026-05-08</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
</urlset>
```
Footnote (retired submission endpoints): search-engine submission and ping endpoints get retired. A 410 Gone from a submission endpoint means remove the call, not retry it. Rely on the sitemap plus the search console; do not keep POSTing to a dead endpoint.
### Image and Video Sitemap Extensions
For sites where images or video are first-class content (gallery, recipe, product, news, video platform), these extensions improve discovery.
- Required `<urlset>` headers: `xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"` and `xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"`.
- Up to 1,000 image entries per page URL.
- Required video fields: `thumbnail_loc`, `title`, `description`, plus one of `content_loc` or `player_loc`.
- Build the image/video sitemap from the SAME source the page uses, so it stays in sync. A drifted image sitemap (pointing at images the page no longer uses) gets coverage warnings.
```xml
<url>
<loc>https://your-domain.com/article/topic-deep-dive</loc>
<image:image>
<image:loc>https://your-domain.com/images/topic-hero.jpg</image:loc>
<image:title>Topic hero illustration</image:title>
<image:caption>Diagram showing the relationship between A and B.</image:caption>
</image:image>
<video:video>
<video:thumbnail_loc>https://your-domain.com/images/topic-thumb.jpg</video:thumbnail_loc>
<video:title>Topic walkthrough</video:title>
<video:description>Five-minute walkthrough of the topic.</video:description>
<video:content_loc>https://your-domain.com/video/topic.mp4</video:content_loc>
<video:duration>312</video:duration>
</video:video>
</url>
```
### Meta Robots
```html
<meta name="robots" content="index, follow" />
```
- Use `noindex, nofollow` on: pages behind authentication, internal search result pages with no indexable value, duplicate/near-duplicate pages, thin content (tags, archives, pagination beyond page 1), and print stylesheets/embed-only pages.
- `X-Robots-Tag` HTTP header is equivalent and works for non-HTML resources (PDFs, images): `X-Robots-Tag: noindex, nofollow`.
- Pair the signals: a `noindex` page must be EXCLUDED from the sitemap (a `noindex` page listed in the sitemap triggers a "Submitted URL marked noindex" issue). Indexable pages get `index` plus a sitemap listing; non-indexable pages get `noindex` plus absent from sitemap.
### AI Answer Engines (AEO / GEO)
Generative search is a real discovery and citation surface. Make the site quotable, not just rankable.
- Publish `/llms.txt`: a short overview of the site plus a flat index of canonical pages (title and URL per line).
- Optionally publish `/llms-full.txt`: the load-bearing facts an answer engine quotes verbatim (what the product does, numbers, pricing, data model), in plain prose, consistent with rendered pages and structured data.
- Ensure major AI crawler user-agents are NOT blocked in `robots.txt` and that `robots.txt` references the sitemap.
- Precedence: a named user-agent group fully REPLACES the `*` group for that agent (rules do not merge), so a dedicated AI group must REPEAT every `Disallow` you still want enforced. A blanket `Allow: /` in a named group silently exposes the `/api/` or `/private/` paths the `*` group disallows. Add a dedicated group only to set DIFFERENT rules than `*`.
- State load-bearing facts in server-rendered text: an engine quotes rendered text and valid structured data, so facts only in an image, only in client-rendered JS, or only in the JSON-LD are at risk.
```
# Only needed if these agents need DIFFERENT rules than User-agent: *.
# A named group REPLACES the * group for that agent, so repeat your Disallows.
User-agent: GPTBot
User-agent: ClaudeBot
User-agent: PerplexityBot
User-agent: Google-Extended
User-agent: CCBot
User-agent: Applebot-Extended
Disallow: /api/
Disallow: /private/
Allow: /
Sitemap: https://example.com/sitemap.xml
```
The AEO check: `llms.txt` exists and lists every canonical page; `robots.txt` does not block AI user-agents and references the sitemap; facts an engine would quote appear in server-rendered HTML, not only in images or JS.
### Canonical Tag
Every indexable page has a self-referencing canonical.
```html
<link rel="canonical" href="https://example.com/pricing" />
```
| Rule | Detail |
| --- | --- |
| Absolute | Includes protocol and domain. |
| Normalized | Lowercased, with the consistent trailing-slash convention you have chosen. |
| No tracking params | No `utm`, `fbclid`, `gclid`. |
| Paginated lists | Each page canonicals to itself; do not canonicalize page 2 to page 1. |
| Filter/facet variants | Canonicalize variants to the unfiltered root only when the filter does not change indexable content. |
### Pagination Signals (post rel=prev/next deprecation)
`rel=prev` and `rel=next` were deprecated as indexing signals in 2019.
- Self-canonical every paginated page: page 2 of `/blog` carries `<link rel="canonical" href="https://your-domain.com/blog?page=2">`. Do NOT canonicalize page 2 to page 1 (drops page-2 content from the index).
- Each paginated page is indexable (no `noindex` on page 2 onwards), except when page-N pages add no incremental value (thin long-tail matches): then `noindex` and exclude from sitemap.
- Internal-link signal replaces the deprecated rel attributes: each paginated page links to siblings (previous, next, first, last, range around current) via real `<a href>` tags, not JS-firing buttons, so crawlers follow them.
- The canonical URL shape must be stable: `/blog?page=2` vs `/blog/page/2` vs `/blog/2` are different URLs; pick one and 301 the others. The sitemap lists every paginated page or none.
## Titles and Descriptions
### Title Tag
```html
<title>Page topic - Brand</title>
```
| Rule | Detail |
| --- | --- |
| Unique | Unique per page across the entire site. |
| Length | 50-60 characters before truncation in the SERP, measuring RENDERED length, not raw HTML source. |
| Decode entities first | `&` is one character in the SERP, not five; `'` is one, not five. A naive `.length` on the raw `<title>` over-counts and triggers false "too long" fixes, so decode entities (or read `document.title`) before comparing to 60. |
| Intent placement | Primary intent term near the beginning when natural. |
| Brand | At the end (separator: a pipe or a hyphen); some sources advise omitting the brand on home/category pages where the SERP already attaches it. |
| Hygiene | No keyword stuffing, no all caps, no emoji unless the brand allows. |
| Templated overflow | On templated/detail pages, drop the trailing Brand suffix to avoid pushing past 60 chars (otherwise the SERP title gets rewritten); keep the brand only where the base title is short (home, top-level hubs). Measure the longest realistic generated title. |
### Meta Description
```html
<meta name="description" content="One sentence stating what this page covers and the value the reader gets, in plain language, with one explicit or implicit call to action." />
```
- Unique per page.
- 140-160 characters.
- Uses the primary intent term naturally, with a clear value proposition and an implicit or explicit call to action.
## Heading Structure
- One `<h1>` per page; the H1 is the primary intent.
- Sequential headings `<h2>` to `<h3>` and onward, no skipped levels.
- Headings describe content, not styling.
- Sections labeled by their heading via `aria-labelledby` for accessibility (also reinforces structure for some bots).
```html
<main>
<h1>Page primary intent (one H1)</h1>
<section aria-labelledby="section-1">
<h2 id="section-1">First section heading</h2>
...
</section>
<section aria-labelledby="section-2">
<h2 id="section-2">Second section heading</h2>
<h3>Sub-section</h3>
...
</section>
</main>
```
## URL Structure
| Rule | Detail |
| --- | --- |
| Lowercase | URLs are lowercase. |
| Hyphenated | Hyphen-separated words. |
| Descriptive | Keyword-rich where natural. |
| No tracking params | None in the canonical. |
| Trailing slash | One consistent policy across the site. |
| Short | Prefer `/topic/specific-slug` over `/2026/05/08/post-id-123`. |
| Stable | Once published, URLs do not change; if one must change, return 301 from old to new. |
### Clean URLs on a static host (three-part contract)
Clean (extensionless) URLs on a static host require three things kept consistent:
1. Emit extensionless files.
2. Keep one consistent no-trailing-slash policy.
3. Strip the extension from BOTH the canonical AND the sitemap, so the served URL is the canonical one.
Gotcha: when the build output is flat `page.html` files (not `page/index.html` directories), naive tooling (a raw static file server, a directory-index walker, a link checker, a Lighthouse run pointed at a bare static server) 404s or serves the wrong file for an extensionless request, producing phantom failures absent on the real host. Audit against the framework's own preview server (or the edge), which reproduces the host URL rewriting. Spot check that `/page` (no extension, no slash) returns 200 with the right document.
## Open Graph and Twitter Cards
```html
<meta property="og:type" content="website" />
<meta property="og:title" content="Page topic - Brand" />
<meta property="og:description" content="..." />
<meta property="og:url" content="https://example.com/page-path" />
<meta property="og:image" content="https://example.com/og/page.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:site_name" content="Brand" />
<meta property="og:locale" content="en_US" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="..." />
<meta name="twitter:description" content="..." />
<meta name="twitter:image" content="https://example.com/og/page.png" />
<meta name="twitter:site" content="@brand" />
```
- Set `og:type` per page type, do not hardcode: `article` on articles/blog posts/detail pages (product, case study, item); `website` on the home page and section hubs.
- Article-typed pages should carry `article:published_time` and `article:modified_time` when available.
- Image: 1200x630 PNG or JPEG; under 5 MB, aim for under 500 KB; text legible at thumbnail size; do not render critical info only in the image (the description should stand on its own).
### Generating Per-Template OG Images
Generate at build time so every page ships a distinct, on-brand 1200x630 image.
- Pipeline: render a template to SVG, then rasterize the SVG to PNG, as a prerendered per-template endpoint, for zero runtime cost on static hosts.
- Font gotcha: the SVG text layout step needs a `ttf`, `otf`, or `woff` font and cannot read `woff2`. Source a `woff` or `ttf` explicitly, since variable-font packages often ship `woff2` only.
- Bundler gotcha: a native-addon rasterizer must be marked external to the bundler (the SSR external list plus the dependency-optimizer exclude list), or the build fails trying to bundle a binary.
- A single designed static image is a fine default and home-page image; generate per-template images for the long tail (articles, items, detail pages), kept under 500 KB and legible at thumbnail size.
## Structured Data (JSON-LD)
Add to `<head>` as one `<script type="application/ld+json">` block per type, or a graph block with multiple types.
### Organization (site-wide, home and about)
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Brand",
"url": "https://example.com",
"logo": "https://example.com/logo.png",
"sameAs": [
"https://twitter.com/brand",
"https://github.com/brand",
"https://linkedin.com/company/brand"
]
}
</script>
```
### WebSite with SearchAction (home only)
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"url": "https://example.com",
"potentialAction": {
"@type": "SearchAction",
"target": "https://example.com/search?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
```
### BreadcrumbList (every interior page)
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://example.com" },
{ "@type": "ListItem", "position": 2, "name": "Section", "item": "https://example.com/section" },
{ "@type": "ListItem", "position": 3, "name": "Current page" }
]
}
</script>
```
Build this JSON-LD from the SAME ordered array that renders the visible breadcrumb UI, so the structured data and the on-page trail never disagree (mismatched breadcrumbs are a common Rich Results warning). Emit it on every interior page that shows a breadcrumb, including legal and utility pages.
### Article (blog posts, news)
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "...",
"description": "...",
"image": "https://...",
"datePublished": "2026-05-08",
"dateModified": "2026-05-09",
"author": { "@type": "Person", "name": "...", "url": "..." },
"publisher": { "@type": "Organization", "name": "Brand", "logo": { "@type": "ImageObject", "url": "..." } },
"mainEntityOfPage": "https://..."
}
</script>
```
### FAQPage (FAQ sections)
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "Question text exactly as displayed?",
"acceptedAnswer": { "@type": "Answer", "text": "Answer text exactly as displayed." }
}
]
}
</script>
```
- Only use `FAQPage` when the FAQ is genuinely on the page and visible to users.
- SERP-feature note: FAQ rich results were rolled back (limited, then effectively retired between 2023 and 2026) and HowTo rich results were retired (2023). Keep accurate, parity-correct `FAQPage`/`HowTo` markup (still valid, feeds AI answer engines) but do not plan around them as winnable rich-result features.
- Highest-ROI structured data for most sites today: Organization, WebSite, BreadcrumbList, Article, Product or SoftwareApplication, and VideoObject.
### Product / SoftwareApplication / HowTo / Recipe / Event / etc.
Use the schema.org type that best matches the page content, and validate every JSON-LD with the Rich Results Test before declaring it complete.
### Never Fabricate Structured Data
Structured data is a verifiable, penalizable claim. Emit only what is real.
- No invented `aggregateRating` or `review`. Add a rating object only when real, on-site or first-party reviews back it; fabricated ratings risk manual action and are stripped when they do not match visible content.
- `sameAs` lists only profiles that actually exist and belong to the entity. Build from a real list and emit nothing when empty (no placeholder social URLs).
- Emit `WebSite` `SearchAction` only when a working search endpoint exists at the `target` URL. A `SearchAction` pointing at a non-existent search is a defect, not an enhancement.
- Parity rule: every value in the JSON-LD should be derivable from visible page content. If it is not on the page, it does not belong in the markup.
### Validation
| Tool | Use |
| --- | --- |
| Rich Results Test (https://search.google.com/test/rich-results) | Renders JS, finds JSON-LD, shows what can be extracted. |
| Schema.org Validator (https://validator.schema.org/) | Structural validation. |
| Lighthouse SEO category | Does NOT validate JSON-LD content; use the dedicated tools above. |
## Image SEO
- Descriptive file names: `topic-comparison-chart.png`, not `IMG_2034.png`.
- `alt` text describing the image (for accessibility AND SEO).
- Compress to AVIF/WebP per [performance.md](performance.md).
- For images that are themselves content (infographics, charts), provide a long-form description nearby.
- For decorative images, `alt=""`; search engines understand this signal.
## Internal Linking
- Every important page should be reachable within 3 clicks of the home page.
- Use descriptive anchor text, not "click here", "read more", "learn more"; use the destination's title or a topical phrase.
- Avoid orphan pages (no internal links pointing to them).
- Avoid over-linking (every navigation item duplicated 5x in body).
- Use a hub-and-spoke model: cluster pages link to a central topical hub.
## Hreflang (Multilingual)
For each locale variant of a page, list all variants including the page itself.
```html
<link rel="alternate" href="https://example.com/pricing" hreflang="en" />
<link rel="alternate" href="https://example.com/de/pricing" hreflang="de" />
<link rel="alternate" href="https://example.com/ja/pricing" hreflang="ja" />
<link rel="alternate" href="https://example.com/pricing" hreflang="x-default" />
```
- Mutual: every variant lists every other variant. Self-referencing: each variant lists itself.
- Include `x-default` for the unmatched/default version.
- Use valid BCP 47 codes (`en`, `en-US`, `de`, `pt-BR`).
### URL Strategy: ccTLD vs Subdirectory vs Subdomain
Pick once and stay.
| Strategy | Example | Geo signal | Tradeoffs |
| --- | --- | --- | --- |
| ccTLD | `your-brand.de`, `your-brand.jp` | Strongest country signal (the TLD itself geo-targets) | Highest cost (separate domain, separate SEO authority per TLD); needed for jurisdictions that require local presence |
| Subdirectory | `your-brand.com/de/`, `your-brand.com/ja/` | No automatic country signal; set via search console international targeting or via `hreflang` | Cheapest; parent domain authority flows down; preferred default for most sites |
| Subdomain | `de.your-brand.com`, `ja.your-brand.com` | Treated as a separate site; geo signal via search console settings | Mid cost; authority partially inherits; harder to maintain consistent UX |
Do not mix strategies (one ccTLD, several subdirectories, and a stray subdomain). Audit the inventory before adding the next locale.
### Content-Language vs hreflang
- `Content-Language: en-US` HTTP header (and `<meta http-equiv="content-language">` and `<html lang>`) declares the language of the CURRENT page. Useful for content-negotiation proxies and screen readers, but not a primary SERP-targeting signal.
- `hreflang` declares the FULL SET of language and region variants and is the SERP-targeting signal used to pick which variant to surface. When `hreflang` and `Content-Language`/`<html lang>` disagree, `hreflang` is trusted. Set both correctly.
### Geo-IP Redirects Are a Policy Violation
Auto-redirecting based on the user's IP location is a policy violation: the crawler crawls from US IPs, so it never sees the localized variants and may de-rank or de-index them.
- Show a banner offering the localized variant; do not force-redirect.
- Persist the user's locale choice in a cookie or in the URL; honour it on the next visit.
- For multi-region commerce (currency, shipping), let the user pick the region from a visible control; do not infer-and-redirect.
## Mobile-Friendly
- Responsive design (no separate `m.` site).
- `<meta name="viewport" content="width=device-width, initial-scale=1">`.
- No horizontal scroll at 320px width.
- Touch targets >= 44x44 CSS pixels.
- Body text >= 16px on mobile (avoids iOS auto-zoom).
- Same content as desktop (mobile-first indexing).
## Page Speed (CrUX, not Lighthouse)
The search console uses CrUX (Chrome User Experience Report) p75 over 28 days for the Page Experience signal. Lighthouse is a lab proxy; CrUX is the real signal.
Targets (CrUX p75):
| Metric | "Good" threshold |
| --- | --- |
| LCP | <= 2.5s |
| INP | <= 200ms |
| CLS | <= 0.1 |
A page can be Lighthouse 95 in the lab and still "Needs Improvement" in CrUX if real users have slower devices/networks. Instrument the field with a `web-vitals` collector and track p75.
### Search Console Page Experience and CrUX Mapping
- 28-day rolling p75: CrUX aggregates the trailing 28 days of real-user data. A fix shipped today shows up incrementally; full credit lands about 28 days later when the pre-fix window has rolled off. The dashboard after a ship is mostly old code for a month.
- CrUX origins map to URL groups, not individual URLs: the search console groups URLs by template (homepage, article, product page) and reports CWV per group. The group's p75 triggers the warning, and a fix must land on enough pages in the group to move the p75.
- The PageSpeed Insights API exposes the same CrUX data per URL and per origin. Pull it daily into your observability stack to watch the trend without waiting for the UI to refresh. See [observability.md](observability.md) for the field-monitoring pipeline.
## Third-Party-Cookie Deprecation Impact
Chrome's third-party cookie deprecation (3PCD), reversed and re-staged through 2024 to 2026, has already moved Safari and Firefox to no-3PC by default. Plan for it as the baseline.
### What Breaks When 3PCs Are Blocked
| Surface | Breakage | Mitigation |
| --- | --- | --- |
| Cross-domain analytics | GA4, Adobe Analytics lose session stitching across subdomains/partner domains | First-party measurement: server-side tagging, GA4 Measurement Protocol, first-party `_ga` cookie on your domain |
| A/B testing platforms | Optimizely, VWO setting assignment cookies as third parties lose stable assignment | Move assignment to a first-party cookie set by your server, or to a server-rendered query parameter |
| Cross-domain embeds | YouTube, Vimeo, Twitter, social embeds lose logged-in state, view tracking, recommendations | The embed still loads; personalization does not |
| Federated auth iframes | OAuth/SSO silent re-auth and session-bridge cookies break; user must re-authenticate | Storage Access API + CHIPS migration path (below) |
| Cross-site retargeting and attribution | Display ads, retargeting pixels, last-click cross-domain attribution lose fidelity | Privacy Sandbox APIs (Topics, Protected Audience, Attribution Reporting) where available |
### What Still Works
- First-party cookies on your own origin: the browser trusts cookies set by the same registrable domain the user is visiting.
- Server-side analytics: logs, server-side GA4 via the Measurement Protocol, edge-collected events with a first-party cookie carrier.
- First-party identifiers in URLs: magic-link tokens, signed query parameters, server-rendered IDs.
### Storage Access API and CHIPS
- Storage Access API: a third-party iframe (help widget, auth iframe) calls `document.requestStorageAccess()` in response to a user gesture; the browser prompts the user; granted access lets the iframe read and write its own cookies. Use for federated auth iframes and consented embeds; design for the permission-prompt friction. See [auth.md](auth.md) for the auth-flow integration.
- CHIPS (Cookies Having Independent Partitioned State): a `Set-Cookie` with the `Partitioned` attribute stores a separate cookie per top-level site that embeds the third party. Useful for third-party widget session state (support chat, embed) without cross-site tracking; invisible to other top-level sites that embed the same third party.
- Sites hosting embedded surfaces (a SaaS dashboard users embed in intranets, a chat widget) should adopt `Partitioned` cookies now, so the same widget code works across legacy and post-3PCD browsers.
```http
Set-Cookie: __Host-session=abc; Path=/; Secure; HttpOnly; SameSite=None; Partitioned
```
## Content Quality (E-E-A-T)
Quality raters apply the E-E-A-T framework:
| Pillar | Signal |
| --- | --- |
| Experience | First-hand experience visible (case studies, original screenshots, "I tested this and..."). |
| Expertise | Author has demonstrable subject knowledge. |
| Authoritativeness | Cited by others, recognized in the space. |
| Trustworthiness | Accurate facts, transparent business, contact info, privacy policy, secure (HTTPS). |
For YMYL (Your Money Your Life) topics (medical, legal, financial), expertise and trustworthiness are critical: author bios with credentials, editorial policy, sources cited inline, updated/published dates, contact and About pages. For non-YMYL, expertise is useful but less strict.
## Render Primary Content on the Server
Crawlers (search and AI) index what is in the HTML response. Text that appears only after client-side hydration (inside an island, a partial-hydration region, or any JS-only render) may never be indexed and is invisible to crawlers that do not execute JS.
- Server-render (SSR or SSG) all primary content and any text inside interactive components, then hydrate for interactivity.
- For tabbed content (for example terms and privacy panels), server-render every panel's content in static HTML so all of it is indexable, then enhance into tabs (roving tabindex, a deep-linkable active tab; see accessibility.md Tabs).
- Verify by viewing source (not the DevTools DOM, which shows post-JS state) or by loading with JavaScript disabled. Every load-bearing sentence, price, and heading must be present.
- A partially-hydrated widget rendering only its active state in static HTML (for example a toggle showing one of three values) is acceptable for the visible state, but put the full set of facts somewhere server-rendered (a static list, an `sr-only` block, or the structured data and prose).
## Common SEO Mistakes
- Same title or same description on every page.
- No canonical, or canonical pointing to a different URL by mistake.
- Multiple H1s per page; skipped heading levels.
- Important content hidden behind JS that does not render server-side, or in images without alt text.
- Slow LCP because the hero image is not optimized; CLS from a late-loading hero or font swap.
- Internal links with anchor text "click here" or "read more".
- Sitemap listing 404 or `noindex` URLs.
- `robots.txt` blocking CSS or JS (the crawler needs to render the page).
- `noindex` on a page that should be indexed (typo or stale config).
- Tracking params in the canonical (creates infinite duplicates).
- Migrating URLs without 301 redirects.
- Blocking the staging domain in `robots.txt` while allowing production by mistake.
## Pre-Publish SEO Checklist
For every public-visible page:
- [ ] Unique `<title>` 50-60 chars
- [ ] Unique `<meta name="description">` 140-160 chars
- [ ] One `<h1>` matching primary intent
- [ ] Sequential headings, no skipped levels
- [ ] Self-referencing `<link rel="canonical">`
- [ ] `<meta name="robots" content="index, follow">` if indexable; `noindex` if not
- [ ] Open Graph tags (og:title, og:description, og:image, og:url, og:type)
- [ ] Twitter card tags
- [ ] Lang attribute on `<html>`
- [ ] Structured data validated via Rich Results Test (where applicable)
- [ ] Image alt text on every meaningful image
- [ ] Internal links use descriptive anchor text
- [ ] Page reachable from home in <= 3 clicks
- [ ] Listed in sitemap.xml (if indexable)
- [ ] HTTPS, no mixed content
- [ ] Mobile-friendly (responsive, viewport meta, no horizontal scroll)
- [ ] CrUX p75 LCP/INP/CLS in "Good" zone (verify after 28 days of traffic)
- [ ] No render-blocking content critical to indexing (JS is rendered but slowly; SSR/SSG preferred for primary content)
## See Also
- [lighthouse.md](lighthouse.md) for the SEO category audits in detail
- [performance.md](performance.md) for Core Web Vitals optimization
- [accessibility.md](accessibility.md) for the accessibility/SEO overlap (alt text, headings, labels)
- [observability.md](observability.md) for the CrUX and field-monitoring pipeline
scripts/validate-structure.mjs
#!/usr/bin/env node
// Structural validator for skill-frontend-excellence.
// Enforces the navigation + frontmatter contract that the skill's determinism rests on:
// 1. Every references/*.md has complete, well-formed YAML frontmatter (7 fields).
// 2. load-when.task-keywords / load-when.symptoms are drawn from the closed vocabulary below.
// 3. title matches the file's H1.
// 4. related: lists 2 to 4 sibling files that all resolve.
// 5. size: matches actual line count (auto-fixed with --fix, else a failure).
// 6. SKILL.md routing integrity: every reference file appears in the Reference Index AND in
// at least one of the By-Task / By-Symptom tables (no orphan); every routed link resolves;
// every section-pointer anchor (#slug) resolves to a real heading in the target file.
//
// ESM, stdlib only. Run: node scripts/validate-structure.mjs [--fix]
// Exit 0 = clean. Exit 1 = one or more violations (or a size drift in non-fix mode).
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
import { join, dirname, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import process from 'node:process';
const HERE = dirname(fileURLToPath(import.meta.url));
const FIX = process.argv.includes('--fix');
const SIZE_TOLERANCE = 8; // lines; size: drift beyond this is a violation (auto-fixed with --fix)
// ---------------------------------------------------------------------------
// Closed vocabulary. Single source of truth, mirrored for humans in CONTRIBUTING.md.
// Adding a keyword/symptom to a reference file requires adding it here in the same change.
// ---------------------------------------------------------------------------
const TASK_KEYWORDS = new Set([
// Core Web Vitals and perf
'LCP', 'INP', 'CLS', 'TTFB', 'FCP', 'TBT', 'performance', 'hydration', 'bundle', 'preload',
'prefetch', 'Speculation Rules', 'Early Hints', 'fetchpriority', 'BFCache', 'render strategy',
'SSR', 'SSG', 'CSR', 'ISR', 'streaming', 'islands', 'partial hydration', 'resumable',
// Accessibility
'accessibility', 'a11y', 'WCAG', 'screen reader', 'keyboard', 'focus', 'contrast',
'semantic HTML', 'ARIA', 'dynamic type', 'reduced motion', 'forced colors', 'axe', 'inline SVG',
// SEO
'SEO', 'indexing', 'canonical', 'sitemap', 'robots', 'structured data', 'JSON-LD', 'AEO', 'GEO',
'llms.txt', 'hreflang', 'Open Graph', 'meta description', 'title tag', 'clean URLs',
// UI / UX
'UI', 'UX', 'interaction', 'hover', 'press', 'loading state', 'error state', 'empty state',
'success state', 'modal', 'popover', 'dialog', 'drawer', 'sheet', 'menu', 'tooltip', 'snackbar',
'toast', 'breadcrumb', 'navigation', 'touch target', 'hit target', 'popover API', 'inert',
// Design
'design', 'typography', 'color', 'palette', 'OKLCH', 'P3', 'wide gamut', 'spacing', 'composition',
'atmosphere', 'dark mode', 'light mode', 'brand', 'font', 'variable font',
// Responsive
'responsive', 'breakpoint', 'mobile', 'tablet', 'desktop', 'container query', 'viewport',
'safe area', 'dvh', 'srcset', 'DPR', 'fluid typography', 'subgrid', 'scrollbar-gutter',
// Motion
'motion', 'animation', 'transition', 'easing', 'View Transitions', 'scroll-driven', 'WAAPI',
'will-change', '@starting-style',
// Forms
'form', 'validation', 'autofill', 'autocomplete', 'label', 'input', 'select', 'checkbox',
'radio', 'file upload', 'constraintValidation',
// Data viz
'chart', 'data viz', 'axis', 'legend', 'colorblind', 'Canvas', 'SVG', 'WebGL', 'timezone', 'DST',
// Pre-launch
'pre-launch', 'checklist', 'ship', 'release', 'deployment', 'gate', 'verification', 'evidence',
// Audit / polish
'audit', 'route', 'sweep', 'screenshot', 'baseline', 'capture', 'polish', 'drift', 'measurable bars',
// Components
'component', 'widget', 'contract', 'extraction', 'slots', 'composition', 'Storybook', 'tokens',
// Defects
'defect', 'bug', 'regression', 'geometry', 'threshold',
// Security
'security', 'CSP', 'COOP', 'COEP', 'CORP', 'cross-origin isolation', 'SRI', 'Trusted Types',
'Permissions-Policy', 'Referrer-Policy', 'frame-ancestors',
// Observability
'observability', 'RUM', 'monitoring', 'error capture', 'source maps', 'INP attribution',
'Reporting API', 'error boundary', 'CrUX', 'Long Animation Frames', 'LoAF',
// Testing
'testing', 'visual regression', 'axe-core', 'pa11y', 'size-limit', 'bundlesize', 'lighthouse-ci',
'contract test', 'type check', 'hermetic gate',
// Lighthouse (audit-id space)
'lighthouse', 'diagnostic Insights', 'phantom failure', 'errors-in-console', 'image-size-responsive',
// Auth
'auth', 'authentication', 'login', 'signup', 'passkey', 'WebAuthn', 'OAuth', 'magic link',
'session', 'account recovery', 'CAPTCHA', 'Turnstile', 'Storage Access API',
// Debug
'debug', 'recipe', 'hydration mismatch', 'layout overflow', 'focus trap', 'font-swap CLS',
// Anti-patterns
'anti-pattern', 'what to avoid', 'mistake',
// i18n
'i18n', 'l10n', 'internationalization', 'localization', 'locale', 'translation', 'Intl',
'plural rules', 'bidi', 'RTL', 'mirroring',
// PWA / offline
'PWA', 'offline', 'service worker', 'SW', 'install prompt', 'push', 'background sync', 'manifest',
// Build hygiene
'build', 'tree-shaking', 'dependency', 'sideEffects', 'lockfile', 'dead code', 'code splitting',
// Embed patterns
'embed', 'iframe', 'sandbox', 'postMessage', 'host', 'guest', 'third-party widget',
// Print / email
'print', 'email', '@page', 'page-break', 'transactional email', 'Outlook',
// Quick reference
'rule', 'quick reference', 'highest leverage',
]);
const SYMPTOMS = new Set([
'LCP > 2.5s', 'LCP regression', 'INP > 200ms', 'INP regression', 'CLS > 0.1', 'CLS regression',
'slow page', 'slow interaction', 'score dropped', 'Lighthouse score drop', 'bundle size grew',
'hydration mismatch', 'focus trap leak', 'duplicate id', 'horizontal scroll', 'viewport overflow',
'broken on Firefox', 'broken on Safari', 'font swap CLS', 'iOS 100vh', 'rubber-band scroll',
'third-party script slow', 'focus not visible', 'contrast fail', 'aria-hidden leak', 'inert leak',
'noindex with sitemap', 'canonical mismatch', 'consent banner CLS', 'popover not dismissing',
'scroll lock side shift', 'auth redirect loop', 'passkey not offered', 'RTL broken',
'dark mode broken',
// new with v0.3.0 additions
'phantom dev failure', 'errors-in-console', 'image too small on retina', 'stale SRI beacon',
]);
const REQUIRED_FM = ['title', 'purpose', 'load-when', 'prereq', 'related', 'size'];
const errors = [];
const fixes = [];
function err(file, msg) { errors.push(`${file}: ${msg}`); }
function slugify(heading) {
// GitHub-flavored heading anchor: lowercase, drop anything not alnum/space/hyphen, spaces->hyphens.
return heading.toLowerCase().replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-');
}
function parseFrontmatter(text) {
if (!text.startsWith('---\n')) return null;
const end = text.indexOf('\n---', 4);
if (end === -1) return null;
return text.slice(4, end);
}
// Tiny purpose-built YAML reader for our fixed schema (avoids a dep).
function readField(fm, name) {
const m = fm.match(new RegExp(`^\\s*${name}:\\s*(.*)$`, 'm'));
return m ? m[1].trim() : null;
}
function readListField(fm, name) {
// inline [a, b] form (allow leading indentation for nested keys)
const inline = fm.match(new RegExp(`^\\s*${name}:\\s*\\[(.*)\\]\\s*$`, 'm'));
if (inline) return inline[1].split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
return null;
}
function readNestedList(fm, parent, child) {
const re = new RegExp(`^${parent}:\\s*\\n((?:[ \\t]+.*\\n?)+)`, 'm');
const block = fm.match(re);
if (!block) return null;
return readListField(block[1], child);
}
function headings(text) {
return [...text.matchAll(/^##+\s+(.*)$/gm)].map((m) => m[1].trim());
}
async function main() {
const root = dirname(HERE); // scripts/ -> repo root
const refDir = join(root, 'references');
const refFiles = (await readdir(refDir)).filter((f) => f.endsWith('.md')).sort();
// --- per-file frontmatter validation ---
const headingsByFile = {};
for (const f of refFiles) {
const path = join(refDir, f);
const text = await readFile(path, 'utf8');
headingsByFile[f] = new Set(headings(text).map(slugify));
const fm = parseFrontmatter(text);
if (!fm) { err(f, 'missing or malformed YAML frontmatter block'); continue; }
for (const field of REQUIRED_FM) {
if (!new RegExp(`^${field}:`, 'm').test(fm)) err(f, `frontmatter missing required field: ${field}`);
}
const title = readField(fm, 'title');
const h1 = (text.match(/^#\s+(.*)$/m) || [])[1];
if (title && h1 && title.trim() !== h1.trim()) err(f, `title "${title}" does not match H1 "${h1}"`);
const tks = readNestedList(fm, 'load-when', 'task-keywords') || [];
const syms = readNestedList(fm, 'load-when', 'symptoms') || [];
if (tks.length < 6 || tks.length > 12) err(f, `task-keywords count ${tks.length} outside 6..12`);
if (syms.length < 3 || syms.length > 8) err(f, `symptoms count ${syms.length} outside 3..8`);
for (const k of tks) if (!TASK_KEYWORDS.has(k)) err(f, `task-keyword not in closed vocabulary: "${k}"`);
for (const s of syms) if (!SYMPTOMS.has(s)) err(f, `symptom not in closed vocabulary: "${s}"`);
const related = readListField(fm, 'related') || [];
if (related.length < 2 || related.length > 4) err(f, `related count ${related.length} outside 2..4`);
for (const r of related) {
if (!refFiles.includes(r)) err(f, `related points at missing file: ${r}`);
}
// size: actual line count
const actual = text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
const sizeRaw = readField(fm, 'size') || '';
const declared = parseInt((sizeRaw.match(/(\d+)/) || [])[1] || '0', 10);
if (Math.abs(declared - actual) > SIZE_TOLERANCE) {
if (FIX) {
const fixed = text.replace(/^size:.*$/m, `size: ~${actual} lines`);
await writeFile(path, fixed);
fixes.push(`${f}: size ~${declared} -> ~${actual}`);
} else {
err(f, `size: ~${declared} but actual ${actual} (drift > ${SIZE_TOLERANCE}; run --fix)`);
}
}
}
// --- SKILL.md routing integrity ---
const skillPath = join(root, 'SKILL.md');
const skill = await readFile(skillPath, 'utf8');
const section = (name) => {
// No 'm' flag: `$` must mean end-of-string so the body runs to the next "## " or EOF,
// not end-of-first-line.
const re = new RegExp(`## ${name}[\\s\\S]*?(?=\\n## |$)`);
return (skill.match(re) || [''])[0];
};
const linkTargets = (s) => [...s.matchAll(/\]\(references\/([a-z0-9-]+\.md)(#[a-z0-9-]+)?\)/g)]
.map((m) => ({ file: m[1], anchor: m[2] ? m[2].slice(1) : null }));
const byTask = section('Routing: by task');
const bySymptom = section('Routing: by symptom');
const refIndex = section('Reference Index');
const taskFiles = new Set(linkTargets(byTask).map((t) => t.file));
const symFiles = new Set(linkTargets(bySymptom).map((t) => t.file));
const indexFiles = new Set(linkTargets(refIndex).map((t) => t.file));
for (const f of refFiles) {
if (!indexFiles.has(f)) err('SKILL.md', `Reference Index missing row for ${f}`);
if (!taskFiles.has(f) && !symFiles.has(f)) err('SKILL.md', `${f} is orphaned (not in By-Task nor By-Symptom)`);
}
// every routed link resolves + every section anchor resolves
for (const part of [byTask, bySymptom, refIndex]) {
for (const { file, anchor } of linkTargets(part)) {
if (!refFiles.includes(file)) { err('SKILL.md', `routing link to missing file: ${file}`); continue; }
if (anchor && !headingsByFile[file].has(anchor)) {
err('SKILL.md', `section pointer #${anchor} does not match any heading in ${file}`);
}
}
}
// By-Symptom must use markdown links, not bare backtick file names
if (/`[a-z0-9-]+\.md`/.test(bySymptom)) {
err('SKILL.md', 'By-Symptom table still uses backtick file names; use markdown links to references/*.md');
}
// --- report ---
for (const fx of fixes) process.stdout.write(`fixed: ${fx}\n`);
if (errors.length) {
process.stderr.write(`\nvalidate-structure: ${errors.length} violation(s):\n`);
for (const e of errors) process.stderr.write(` - ${e}\n`);
process.exit(1);
}
process.stdout.write(`validate-structure: ok (${refFiles.length} reference files, frontmatter + routing intact)\n`);
}
main().catch((e) => { process.stderr.write(`validate-structure failed: ${e.stack}\n`); process.exit(1); });
references/ui-ux.md
---
title: UI/UX Principles
purpose: Framework-agnostic interaction patterns with concrete thresholds for state coverage, hit targets, modals, popovers, tooltips, navigation, loading, undo, and notification timing. The functional companion to design.md.
load-when:
task-keywords: [UI, UX, interaction, modal, popover, dialog, drawer, menu, tooltip, toast, touch target, navigation]
symptoms: [popover not dismissing, scroll lock side shift, focus trap leak, focus not visible, inert leak, rubber-band scroll]
prereq: SKILL.md
related: [design.md, accessibility.md, forms.md, motion.md]
size: ~600 lines
---
# UI/UX Principles
Framework-agnostic patterns for interfaces that feel professional, predictable, and considered.
## The Hierarchy of UX Concerns
When a UI is "off" but you cannot say why, walk this list top-down. The first failing rung is the problem.
| Step | Question | Checks |
|------|----------|--------|
| 1 | Can the user perceive it? | contrast, size, position, language |
| 2 | Can the user understand it? | vocabulary, hierarchy, affordance |
| 3 | Can the user operate it? | touch target, keyboard, focus, gesture, feedback |
| 4 | Does the user trust it? | consistency, predictability, recovery, transparency |
| 5 | Does it feel good? | polish, motion, sound, haptics, density |
Steps 1 to 3 are correctness, step 4 is reliability, step 5 is craft.
## State Coverage (the most-skipped quality bar)
Every screen and component needs an intentional design for each state. A feature shipped with only the happy path is half-built.
| State | When | What to show |
|-------|------|--------------|
| Empty | No data yet | Specific, helpful message plus a primary action to fix it. Never a blank space. |
| Loading | Async fetch in progress | Skeleton for >300ms loads, spinner for shorter. Match the eventual layout. |
| Success | Action completed | Brief confirmation (toast, inline check, color flash). Move on quickly. |
| Error | Action failed | Cause plus how to fix plus recovery action. Never just "Something went wrong". |
| Partial | Some data, some loading | Render what you have, skeleton the rest. |
| Disabled | Action not available | 40-50% opacity, cursor not-allowed, programmatically disabled, optional explanation. |
| Read-only | Visible, not editable | Visually distinct from BOTH editable AND disabled. |
| Stale | Data older than fresh | Indicate freshness (timestamp, "updated 5m ago"), offer refresh. |
| Offline | No network | Disable network-dependent actions, show offline indicator, queue mutations if possible. |
| Unauthorized | User lacks permission | Explain why, link to upgrade or request access. Never silently disable. |
| Limit reached | Quota exhausted | Explain the limit, show progress, link to upgrade. |
| Initial / first-run | New user | Reduced surface, onboarding hints, sample data. |
| Done, no further action | Complete | Brief celebration, clear next step. |
### Cross-page consistency
A shared component (cards, CTAs, headers, forms, modals) is done only when every state behaves the same on every route that hosts it: match the canonical contract, not the page. When a state differs across two routes the cause is page-local CSS, duplicated markup, or a near-duplicate component. Fix the contract. Extraction and drift detection: see components.md. Cross-page audit procedure: see audit-workflow.md.
### Mobile drawer scroll lock
A mobile drawer (slide-out nav, bottom sheet, full-screen menu) must lock body scroll while open (`overflow: hidden` on the document root) and restore the prior scroll position via `window.scrollTo` on close. Drawer scroll-lock failure is a standard geometry-sweep check: see defects.md.
## Touch and Pointer Interaction
### Hit targets
| Platform | Minimum |
|----------|---------|
| iOS / Web on touch | 44x44 CSS pixels (WCAG 2.5.5 Target Size Enhanced, Level AAA, from WCAG 2.1) |
| Android | 48x48 dp |
| Web on pointer | 24x24 CSS pixels minimum (WCAG 2.5.8 Target Size Minimum, Level AA, added in WCAG 2.2); 44x44 strongly recommended |
Expand the hit area on icon-only buttons that look smaller:
```css
.icon-btn {
width: 24px;
height: 24px;
padding: 10px; /* total 44x44 hit area */
background-clip: content-box;
}
```
Apply the 44px (or 48px) minimum to STANDALONE controls: buttons, toggles, menu triggers, icon buttons, form controls, CTA links. Inline text links, breadcrumb trails, and footer text-link lists are exempt under WCAG 2.5.8 (AA, 24px) because they flow with text. Give them only small vertical padding, not 44px height (a 44px in-prose link reads as broken).
### Spacing between targets
Minimum 8px gap between adjacent interactive targets to prevent mistaps and give focus rings room.
### Hover, press, focus, active
| State | When | Visual change |
|-------|------|--------------|
| Resting | No interaction | The base style |
| Hover | Pointer over, not touch | Subtle elevation, color shift, or underline |
| Focus | Keyboard or programmatic | Visible ring, 2-4px, contrast 3:1 against surface |
| Active / pressed | Mouse down or touch | Slight inset/scale (0.97 transform), darker background, depressed shadow |
| Disabled | Action unavailable | 40-50% opacity, cursor not-allowed, no hover/active response |
Touch devices skip hover. Do not put critical info in a hover-only tooltip.
### Cursor
- `cursor: pointer` on every clickable element. Anti-pattern: a `<button>` with default cursor.
- `cursor: text` on text inputs (native, do not override).
- `cursor: not-allowed` on disabled.
- `cursor: grab` / `grabbing` on draggables.
- `cursor: zoom-in` / `zoom-out` for image zoom.
- `cursor: help` for elements with a `title` or tooltip.
### Press feedback timing
Visual response within 80-150ms of press. Below 80ms feels disconnected, above 200ms feels laggy.
Suppress the grey tap-highlight flash globally, but only when a proper `:focus-visible` ring exists:
```css
:where(a, button, summary, [role="button"], [tabindex]) {
-webkit-tap-highlight-color: transparent;
}
```
### Click vs tap vs hover
- Click/tap: primary actions only.
- Hover: never the only path to an action. Must also be reachable on touch (long-press, kebab menu, or always-visible).
- Long-press: secondary action on touch. Always provide a non-gesture alternative.
- Right-click: context menu only. Always provide an equivalent button or kebab menu.
### Gesture conflicts on scroll containers
A horizontal carousel inside a vertically scrolling page, a pinch-zoom canvas, or a swipeable stack all sit on a scrolling document. Allocate gestures explicitly or the browser fights your handlers.
- `touch-action: pan-y` on a horizontal swiper lets the page own vertical scroll while your code owns horizontal. `touch-action: none` disables browser gestures entirely (use only on canvas / pan-zoom surfaces).
- `overscroll-behavior: contain` on scrolling regions stops scroll chaining into the parent (the "scrolled the modal, then the body scrolled too" bug).
- `overscroll-behavior: none` on `<html>`/`<body>` suppresses pull-to-refresh and the iOS rubber-band bounce for app-shell layouts. Do not apply globally on content sites; users rely on the bounce as feedback.
- Test: scroll a horizontal reel near the top of a long mobile page. Pull-to-refresh must not fire, vertical scroll must continue past the reel, and the reel must not steal vertical drag.
## Visual Hierarchy
Hierarchy tells the user where to look and in what order. Build it from these tools, in priority order:
| # | Tool | Rule |
|---|------|------|
| 1 | Size | Bigger = more important |
| 2 | Weight | Bolder = more important |
| 3 | Contrast | Higher contrast against background = more important |
| 4 | Position | Top and left (in LTR) read first |
| 5 | Color | Warm and saturated draws the eye, muted recedes |
| 6 | Whitespace | Isolation makes things feel important |
| 7 | Motion | Anything that moves draws attention |
Anti-patterns:
- Five things shouting at once.
- Equal weight on everything.
- Color alone for hierarchy. Fails for colorblind users and in monochrome contexts.
- Borders everywhere. Borders compete with content.
### The "primary action" rule
Each screen has exactly one primary CTA. Secondary actions are visually subordinate (outline, ghost, text-only). Tertiary are quieter still (link or icon only). Destructive actions are visually separated AND use a danger color, but are not primary unless the screen is built for that destructive purpose.
### Scan patterns
- Sparse, hero-driven layouts: Z-pattern (top-left to top-right to diagonal to bottom-right). Brand top-left, primary nav top-right, primary CTA bottom-right of the hero.
- Text-heavy layouts: F-pattern (left-edge dominant scan). Important info on the leading edge, sub-headings every 200-400px as scan anchors.
## Density
Density is the ratio of information to space. Match it to the user's task. Mixing densities within one surface feels wrong.
| Density | Surface | Rules |
|---------|---------|-------|
| Low | Hero-driven, single-focus | Generous whitespace, large type, one focus per section |
| Medium | Balanced application screens, mid-length content | Fits the majority of routine UI |
| High | Data tables, code editors, professional tools | Tight spacing, smaller type but still >= 14px, maximum information per pixel |
- Within one density mode, be consistent.
- Provide a "compact / cozy / comfortable" toggle for data-heavy apps.
- Never sacrifice contrast or hit target for density.
## Layout and Spacing
Use a multiplicative scale, never arbitrary values. Pick one and never deviate.
- 4-pt scale: 0, 4, 8, 12, 16, 24, 32, 48, 64, 96.
- 8-pt scale: 0, 8, 16, 24, 32, 48, 64 (a coarser variant, with halves at 4).
Anti-pattern: padding values like 7, 13, 17, 22. They feel random and break rhythm.
### Component spacing tiers
| Tier | Value | Use |
|------|-------|-----|
| Inline | 4-8 | Inside a tag/badge/icon-text pair |
| Tight | 8-12 | Inside a button, input, or card |
| Comfortable | 16 | Between siblings inside a card |
| Section | 24-32 | Between cards or rows in a list |
| Block | 48-64 | Between major sections of a page |
| Page | 96-128 | Around the page edge on large viewports |
Gestalt proximity: things close together read as related. If related items look unrelated, reduce the space; if unrelated items look unified, increase it.
## Typography in UI
See design.md for the type aesthetic. These are the functional rules.
| Use | Mobile | Desktop |
|-----|--------|---------|
| Body | 16px | 16px |
| Body small / caption | 14px | 14px |
| Body large / lead | 18-20px | 18-20px |
| H6 | 16px | 16px |
| H5 | 18px | 18-20px |
| H4 | 20-22px | 22-24px |
| H3 | 22-26px | 24-30px |
| H2 | 28-32px | 32-40px |
| H1 | 32-40px | 40-64px |
| Display (hero) | 40-56px | 56-96px |
Body text below 16px on mobile triggers iOS auto-zoom on focus. Avoid.
- Line height: body 1.5-1.75, headings 1.1-1.3 (tighter), buttons 1 (single line) or 1.2.
- Line length: optimal 60-75 characters, mobile 35-60, desktop cap at 75. Use `max-width: 65ch` (or similar) on long-form content.
- Letter spacing: body default, all-caps eyebrow text +0.05em to +0.1em, display headings -0.01em to -0.02em (slight tighten).
## Color in UI
See design.md for palette construction. This is the semantic system.
Never use raw hex in components. Use semantic tokens, mapped for both light and dark mode:
- `--color-background` (page background)
- `--color-surface` (card / elevated surface)
- `--color-surface-muted` (quieter surface)
- `--color-foreground` (primary text)
- `--color-foreground-muted` (secondary text)
- `--color-foreground-subtle` (tertiary text, hints)
- `--color-border` (resting border)
- `--color-border-strong` (border on focus or emphasis)
- `--color-primary` (brand action)
- `--color-primary-foreground` (text on primary)
- `--color-success`, `--color-warning`, `--color-danger`, `--color-info`
- `--color-focus-ring` (focus indicator)
### Dark mode
- Do not invert. Design dark mode independently.
- Use desaturated, lighter tonal variants of the brand color.
- Surfaces are typically `#0c0c10` or `#111`, not pure black (pure black on OLED creates harsh contrast).
- Borders are typically lighter than the surface, e.g. `rgba(255,255,255,0.08-0.12)`.
- Test contrast in dark mode separately. The 4.5:1 ratios are different.
## Iconography
- One icon set per product. Do not mix sets.
- SVG only. Never emoji as structural icons, never PNG.
- Variable icons (mini/outline/solid families) are fine across hierarchy levels but must be consistent within a level.
- Outline icons: 1.5px or 2px stroke, pick one and stay. Filled icons have no stroke. Do not mix outline and filled in the same hierarchy level.
- Match icon color to surrounding text or use a token (`--color-foreground-muted`). Use semantic color tokens for status, and always pair status icons with a text label (color alone is not enough).
- Alignment: in buttons to the text baseline, in inputs to the input's vertical center, in lists to the same x as adjacent rows.
| Size | Token | Use |
|------|-------|-----|
| 16px | xs | Inline with body text, tight UI |
| 20px | sm | Default in buttons and inputs |
| 24px | md | Nav items, section headers |
| 32px+ | lg | Hero / feature illustrations |
## Buttons
| Level | Visual | Use |
|-------|--------|-----|
| Primary | Filled, brand color | One per screen. The primary action. |
| Secondary | Outlined or quiet filled | Alternative paths. |
| Tertiary | Text-only or ghost | Low-stakes actions. |
| Destructive primary | Filled, danger color | Only on screens built for the destructive action (delete confirmation). |
| Destructive secondary | Outlined, danger color | Listed alongside other actions. |
| Icon-only | Icon, no text | Compact UI. Always include `aria-label`. |
Anatomy:
- Minimum 44x44 touch area.
- Padding: 12-16 horizontal / 8-12 vertical (small); 16-24 horizontal / 10-12 vertical (medium); 24-32 horizontal / 12-16 vertical (large).
- Icon spacing from text: 8px.
- Loading: replace icon with a spinner OR add a spinner before/after text, disable the button, and maintain width to prevent layout shift.
- Disabled: 40-50% opacity, cursor not-allowed, programmatically disabled.
## Forms (functional principles)
Full treatment: see forms.md.
- Visible label for every input. Placeholder is not a label.
- Error inline, near the field, with cause plus fix.
- Validate on blur, not on every keystroke.
- After a submit error, focus the first invalid field.
- Mark required fields (asterisk plus `aria-required="true"`).
- Use the right `type` and `autocomplete` attributes.
- Group related fields with `<fieldset>` plus `<legend>`.
- Long forms autosave drafts. Multi-step forms show progress.
## Modals and Overlays
### Modal
- Reserved for blocking decisions (confirmation, login, critical data entry).
- Always escapable: Esc, backdrop click, close button.
- Focus trapped inside, restored on close.
- Background `inert` or `aria-hidden`.
- Backdrop scrim 40-60% black for legibility.
- Animate from trigger when possible (scale plus fade).
Scroll-lock without a sideways jump: setting `overflow: hidden` on the body hides the scrollbar and widens the page, shifting fixed and centered content sideways. Reserve the width:
```js
const gap = window.innerWidth - document.documentElement.clientWidth;
document.body.style.paddingRight = gap + "px";
// restore overflow and paddingRight on close
```
The gap is 0 on overlay-scrollbar systems, so this is safe everywhere.
### `<dialog>` and `inert` done right
The native `<dialog>` element gives focus management, Escape-to-close, the top layer (z-index is irrelevant), and a real backdrop pseudo-element.
- `dialog.showModal()` is modal mode: the browser makes the rest of the document inert automatically (no manual `inert` on the background), traps focus, intercepts Escape, and renders the backdrop. Use for confirmation, login, critical data entry.
- `dialog.show()` (or `<dialog open>`) is non-modal: no backdrop, no focus trap, the page stays interactive. Use for a non-blocking inspector, a transient picker, an in-page side panel. Do not pair it with `inert` on the background.
- `inert` belongs on the single ancestor containing everything you want to silence (typically the root layout container, with `<dialog>` outside it). Do not sprinkle `inert` on individual elements: you will miss focusable nodes and create the inert-leak symptom (a stray button still tabbable behind a modal).
- Programmatic `dialog.open = true` is a trap: it skips all modal behavior (no backdrop, no focus, no Escape). Always call `showModal()` for modal intent.
- Test: open the dialog, Tab through every control confirming focus never escapes, press Escape, confirm the dialog closes and focus returns to the trigger.
### Sheet / drawer
- Use for non-blocking secondary content (filters, details, settings).
- Slide from edge: left/right for navigation, bottom for mobile contextual.
- Same focus and escape rules as a modal.
- Confirm before dismissing if there are unsaved changes.
### Popover / tooltip
- Tooltip: hover-reveal of supplementary info. Plain text, no interactive content.
- Popover: click-reveal of richer content, can contain interactive elements.
- Both must be keyboard-accessible and dismissible (Esc, click outside).
Wire BOTH dismissal paths on every menu, popover, and disclosure: Escape, and a pointer outside the element. Use `pointerdown` (not `click`) for the outside-dismiss listener: it fires before focus moves and before a `click` that might re-toggle the trigger, and is more reliable on touch. Add the listener only while open, remove on close. Escape returns focus to the trigger.
#### Native HTML Popover API
The `popover` attribute (Baseline 2024, broad support across current browsers) is the default choice for new popovers, menus, and disclosures. It replaces almost every hand-rolled focus-trap plus click-outside plus Escape combo.
```html
<button popovertarget="filters">Filters</button>
<div id="filters" popover>
<p>Filter content here.</p>
</div>
```
What you get for free:
- Top-layer rendering, so the popover sits above every stacking context. No z-index arms race.
- Light-dismiss: a click outside or Escape closes it.
- Focus return: when the popover closes, focus returns to the invoker.
- `popover="manual"` for popovers you control yourself (still top layer, no light-dismiss).
Reach for a JS focus-trap library only when you need behaviour the platform does not provide (a non-closing tour step, or a popover anchored across a scrolling iframe boundary).
#### CSS Anchor Positioning (progressive enhancement)
CSS Anchor Positioning lets the popover position itself relative to its trigger without JavaScript.
```css
.trigger { anchor-name: --filters-anchor; }
#filters {
position-anchor: --filters-anchor;
top: anchor(bottom);
left: anchor(start);
position-try-fallbacks: flip-block, flip-inline;
}
```
Support floor is current evergreen-engine only, with two major engines not yet shipping. Gate the CSS path on `@supports (anchor-name: --x)`, ship a JS portal plus positioning-library fallback, and do not ship anchor-only positioning until the floor includes all major stable engines.
#### Tooltip dismissal and `aria-describedby` timing
WCAG 1.4.13 (Content on Hover or Focus) requires three properties from every tooltip:
- Hoverable: the user can move the pointer onto the tooltip itself without it disappearing.
- Dismissible: Escape (or a similarly easy gesture) closes it without moving pointer focus.
- Persistent: it stays open while the trigger remains focused or hovered. Do not auto-dismiss on a timer.
`aria-describedby` on the trigger should point at the tooltip's `id` only while the tooltip is visible. Add and remove the attribute as the tooltip opens and closes (or use the native Popover API). Setting it permanently on a `display: none` tooltip makes screen readers announce nothing or stutter stale content.
### Toast / snackbar
- Brief notification of system events.
- Auto-dismiss in 3-5s for non-critical; persist for critical with explicit dismiss.
- Do not steal focus. Use `aria-live="polite"` (or `assertive` only for blocking errors).
- Maximum one or two on screen at a time. Stack newer below older, or replace.
## Navigation
| Pattern | Use |
|---------|-----|
| Top app bar | Primary global navigation (web/desktop) |
| Bottom tab bar | Top-level navigation on mobile (max 5 items) |
| Side drawer / sidebar | Secondary nav, deep hierarchy, large screens |
| Hamburger menu | Mobile when bottom nav does not fit; not for primary on desktop |
| Breadcrumbs | Hierarchy 3+ levels deep |
| Tabs | Switching between related views of the same context |
| Segmented control | Two to four mutually exclusive options |
| Stepper / wizard | Multi-step linear flows |
Rules:
- Show where the user is: active tab/route highlighted.
- Predictable back: browser back, swipe-back (iOS), system back (Android) all do the same thing.
- Preserve scroll and state on back navigation.
- Deep linking: every screen has a URL, and sharing a link works.
- Do not mix navigation patterns at the same hierarchy level (no tabs plus sidebar plus bottom nav all primary).
- Persistent: core nav reachable from deep pages.
- Adaptive: large screens prefer sidebar, small screens use bottom/top.
- Destructive actions (delete account, sign out) visually separated from regular nav.
## Loading and Async
Any user action triggering async work needs feedback within 100ms (button state change, immediate spinner, optimistic UI) or the user assumes nothing happened.
| Duration | Pattern |
|----------|---------|
| < 300ms | Nothing. The result will appear. |
| 300ms - 1s | Spinner or progress indicator. |
| 1s+ | Skeleton screen matching the eventual layout. |
| 5s+ | Progress indicator (percent or step count) plus estimated remaining. |
| 10s+ | Allow the user to leave or background the operation. |
- Optimistic UI: for high-confidence operations (like a heart, save a draft), update immediately and reconcile on response. Roll back on error with a clear message.
- Debounce/throttle: search input debounce 200-300ms; auto-save debounce 500-1000ms; window resize throttle 100-200ms; scroll throttle 16ms (one frame) for animation, longer for analytics.
## Empty States
The empty state is content. Treat it as a designed surface.
- Specific message: "No items yet" beats "No data".
- Why it is empty: state the condition that fills it, "Items appear here once you create one."
- Primary action: offer the action that resolves it, e.g. "Create item".
- Optional illustration matching the brand. Do not use a generic stock illustration.
Anti-patterns: blank space; "No results found" with no follow-up; a hopeful illustration with no action.
## Error Recovery
- Cause AND fix in plain language: "Email address is required" beats "Validation error"; "Couldn't connect to server, check your network and try again" beats "Network error".
- Provide the recovery action (retry button, edit field, contact support link).
- Do not blame the user: "We couldn't save your draft" not "You didn't save".
- Preserve user input on error. Never make them retype.
- For 404/500: brand-consistent design, search box if applicable, link home, link to support, optionally a delightful detail.
### Undo and redo beyond the toast
A toast with an "Undo" button is the minimum bar for destructive actions. For composing surfaces (editors, canvas tools, multi-step forms, inline-edit tables) a real undo/redo system is part of the contract.
- Command pattern: every state change is a Command object with `do()` and `undo()` methods. The handler that mutates state always goes through a command, never direct mutation.
- History stack: push executed commands, pop and call `undo()` to reverse. Keep a forward stack for redo, and clear the redo stack on any new command.
- Keyboard hook: Cmd+Z (Ctrl+Z) for undo, Cmd+Shift+Z (or Ctrl+Y) for redo, at document level. Skip when focus is inside a native `<input>` or `<textarea>` so the browser's built-in text undo continues.
- ARIA: use a polite live region to announce "Undid: delete row" so screen reader users get the same feedback as the toast.
- Persistence boundary: decide whether undo crosses save (most editors say no, a save is the commit point and the history clears). State the boundary in the UI.
- Bound the history: cap at 50 to 200 commands depending on memory cost, dropping the oldest when full.
## Notifications and Badges
- Use sparingly. Notification fatigue is real.
- Numeric badges for unread/pending counts, capped at 99+ for sanity.
- Dot badges for "something new" without a count.
- Clear notifications when the user has acknowledged them.
- Group similar notifications (5 likes from 5 people = "5 people liked your post").
- Provide a notification preferences page.
### Notification permission timing
The native push permission prompt is a one-shot resource: a "Deny" verdict is usually irreversible without the user digging into browser settings. Treat the prompt as something you spend.
- Never on load: a prompt within the first 5 seconds of arriving is the canonical dark pattern, and browsers now block prompts that fire without a user gesture.
- After value demonstration: ask only after an action implying the user wants updates (subscribed to a channel, started a chat, opted into alerts, finished onboarding a long-running job).
- In-app preview before native prompt: show an in-app dialog explaining the value ("Get notified when your build finishes") with a primary "Enable" button before calling `Notification.requestPermission()`. This defers the irreversible browser prompt.
- Settings re-entry point: provide a path with copy explaining how to re-enable in the browser if the site was permanently blocked.
## Search
- Search box prominent: top of page or in the global header.
- Show recent searches when empty.
- Show suggestions while typing (debounced).
- Show results in real-time or with a clear submit button.
- Empty result state: "No results for [query]" plus spelling check plus alternate suggestions.
- Search filters: visible, removable individually, with a "clear all".
- Provide recent and saved searches.
## Data Tables
- Sortable columns indicate the current sort with an arrow icon and `aria-sort`.
- Sticky header on long tables.
- Row selection with checkboxes, with select-all in the header.
- Bulk actions appear when one or more rows are selected.
- Inline actions on hover (desktop) or always-visible (touch).
- Pagination or infinite scroll, never both.
- Empty state when no rows.
- Loading skeleton matching the table structure.
## Settings and Preferences
- Group related settings.
- Use the right control: toggle for boolean, radio for one-of-N, checkbox for any-of-N, dropdown for many-of-N, slider for continuous.
- Apply on change, not on submit (confirm only if the change is destructive).
- Show the saved state ("Saved 2s ago" microcopy).
- Reset to default per setting; never a global "reset all".
## Toolbars and Action Bars
- Group actions by relationship.
- Show only the top 3-5 actions; overflow into a kebab menu.
- Destructive actions in the overflow menu unless they are the primary action of the screen.
- Tooltip on icon-only actions.
## Onboarding
- Do not onboarding-dump. Show value first, teach as needed.
- Use empty states to teach (one feature per empty state).
- Use coachmarks sparingly, only for novel patterns the user cannot discover.
- Skippable, always.
- Resumable: the user can return to onboarding from settings.
## Sample Data and First-Run Experience
For surfaces that come alive only after the user contributes data (data tools, editors, analytics views):
- Provide a sample dataset or demo workspace so the user sees the surface working before contributing real data.
- Make it easy to switch between sample and real data.
- The surface should be useful without 30 minutes of setup.
## Microcopy
The text in the UI is part of the design.
- Concrete, not abstract: "Save draft" not "Save", "Delete user" not "OK".
- Active voice: "Choose a plan" not "A plan must be chosen".
- Present tense: "Settings saved" not "Settings have been saved".
- No jargon for end users. Write to the user's vocabulary.
- Consistent: "Sign in" everywhere, not "Log in" / "Sign in" / "Login" mixed.
- Honest: "We can't process this card" not "Something went wrong".
- Brief, cut adjectives: "Saving" not "Now saving your changes".
## Localization
- Do not bake English-specific assumptions into layouts: German is 30% longer, Japanese can be 60% shorter.
- Use logical CSS properties (`margin-inline-start`, `padding-block`) for RTL support.
- Format date, time, number, currency via `Intl.*` APIs, not custom code.
- Use ICU MessageFormat (or framework equivalent) for pluralization.
- Extract translatable strings to a single source. Never inline string concatenation for translatable text.
## Common UX Mistakes
- Hover-only actions on touch devices.
- Modals without focus traps or Esc to close.
- Tooltips with critical information.
- Required fields not marked.
- Error messages without a fix.
- Loading states that disappear before the user perceives them.
- Empty states that are blank.
- Disabled buttons that do not say why.
- Confirmation dialogs for non-destructive actions.
- No confirmation for destructive actions.
- Auto-advancing carousels with no pause.
- Carousels at all (low engagement; static heroes outperform).
- Modal interrupting the user's task.
- Toast that disappears before the user can read it.
- Hamburger menu on a desktop hero where horizontal space is plentiful.
- Search results that change layout shape on every query.
## Self-Healing for UX
Before declaring work complete:
- [ ] Every screen has empty, loading, success, error, and disabled state designs
- [ ] Every interactive element has hover, focus, active, disabled states with visible focus
- [ ] Touch targets >= 44x44 with 8px gaps
- [ ] One primary CTA per screen
- [ ] Visual hierarchy: scan the page; the most important thing is the largest/boldest
- [ ] Density matches the user's job
- [ ] Spacing follows the chosen scale; no random values
- [ ] Iconography: one set, one stroke width, consistent sizes
- [ ] Modals: focus trapped, Esc closes, focus restored
- [ ] Forms: labeled, validated on blur, error inline plus accessible
- [ ] Navigation: where am I, how do I get back, all key screens deep-linkable
- [ ] Loading: feedback within 100ms, skeleton at 300ms
- [ ] Microcopy: concrete, active, present tense, no jargon
- [ ] Tested on smallest target viewport, 200% zoom, dark mode, reduced motion, keyboard only
## See Also
- [design.md](design.md) for aesthetic and brand expression
- [forms.md](forms.md) for form-specific patterns
- [motion.md](motion.md) for state transitions
- [data-viz.md](data-viz.md) for tables and charts
references/testing.md
---
title: Frontend Testing Discipline
purpose: The pre-merge gate. Frontend test pyramid, visual regression, accessibility automation, performance budgets, type and lint gates, contract tests, and lighthouse-ci wiring kept hermetic. The pre-merge counterpart to the post-build polish loop.
load-when:
task-keywords: [testing, visual regression, axe-core, pa11y, size-limit, bundlesize, lighthouse-ci, contract test, type check, hermetic gate, verification, gate]
symptoms: [score dropped, bundle size grew, contrast fail, focus not visible, hydration mismatch]
prereq: SKILL.md
related: [lighthouse.md, observability.md, pre-launch.md, audit-workflow.md]
size: ~302 lines
---
# Frontend Testing Discipline
Tests are the pre-merge gate: what to test, how to budget the categories, how to wire CI so a red gate blocks the merge instead of the deploy. Audit-workflow is the post-build polish loop. Both run; neither replaces the other.
Patterns are stated at the standard level (snapshot diffing, accessibility-tree validation, bundle-size assertion). Tool names appear only as concrete implementations of a pattern.
## The Frontend Test Pyramid
Five categories. Each catches a different regression class. Budget the count per category; spend it on the highest-leverage tests. Add a test at the lowest level that catches the regression class: unit is cheap, end-to-end is slow, flaky, and expensive to maintain.
| Category | Scope | Typical count | Catches |
|----------|-------|---------------|---------|
| Unit | Pure functions, hooks, utilities | Hundreds | Logic regressions, edge cases, refactor breakage |
| Integration | A component plus its immediate collaborators | Dozens to low hundreds | Wiring bugs, prop contracts, state flow |
| Visual | Rendered component or page at fixed viewport | Dozens per surface | Style drift, layout regression, theme breakage |
| End-to-end | Full app, real browser, scripted user flow | Single digits to low dozens per surface | Cross-surface flows, real-browser quirks, integration with auth/payment |
| Contract | API request and response shape | One per consumed endpoint | Backend drift, schema breakage, type-only safety |
- Unit catches what integration mocks pass over: pure-function correctness, sub-second feedback on save.
- Integration catches "the form submits but validation passes when it should fail" wiring bugs unit tests miss.
- Visual catches a shared-token shift that moves a button or drifts a dark-mode background: what unit tests cannot see.
- End-to-end catches surface integration (a flow ending at a 500, a button disabled by an unexpected API shape), not component logic.
- Contract catches a backend field rename deterministically, where mocks still pass and end-to-end caught it only flakily.
Check: every PR touches at least one test, and the test category matches the change category (component edit -> visual test; logic edit -> unit or integration test). PR review questions any change that adds production code without a test.
## Visual Regression Testing
The category most teams skip and most regressions slip through. Pick the tool by tradeoff; the checks below hold across all of them.
| Approach | How it stores baselines | Strength | Weakness |
|----------|-------------------------|----------|----------|
| Headless-browser screenshot diff | PNG baseline committed in git | Zero extra infrastructure, fast feedback | Binary diffs in PRs; OS font rendering drift |
| Git-aware history service | Cloud object storage keyed by git ref | No git bloat, always diffs the actual base, posts side-by-side PR comment | Needs a storage bucket and CI credentials |
| Hosted story-snapshot service (Storybook stories) | Hosted, per story per variant | Auto fan-out across viewport/theme/locale, purpose-built diff review, one-click accept | Hosting cost, story-maintenance coupling |
| Raw pixel matcher | Caller-managed (two PNGs in, diff PNG + count out) | Models one-off pipelines (generated chart, server-rendered email) others cannot | Lowest level, you build the harness |
Worked example. Run from a headless browser of your choice (Puppeteer, Playwright, or equivalent):
```js
import { test, expect } from '<headless-browser-runner>';
test('homepage hero', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('home-hero.png', {
fullPage: false,
mask: [page.locator('[data-volatile="true"]')],
threshold: 0.2,
maxDiffPixelRatio: 0.001,
});
});
```
- Mask volatile regions: timestamps, animated avatars, ad slots. Masked regions are filled with a solid color before diffing.
- Pin the viewport per project, defaulting to 1280x720 desktop and 375x667 mobile; add others when a surface has a breakpoint at risk.
- Set both `threshold` (per-pixel color tolerance) and `maxDiffPixelRatio` (total-pixel tolerance), tuned per surface, never one global value.
- Run visual tests in a containerised CI environment with a pinned OS image; OS font rendering differs, so CI and local otherwise disagree.
- Review baselines in PRs by inspecting the diff PNG written alongside the failure.
Check: every customer-facing surface has at least one visual test; new surfaces get one in the same PR that ships them; a human reviews the diff before the baseline updates.
## Accessibility Automation in CI
Lint catches the mechanical violations (about 30 percent of WCAG); it raises the floor, it does not replace a manual screen-reader pass.
Worked example (axe-core in a headless browser). Run from a headless browser of your choice (Puppeteer, Playwright, or equivalent):
```js
import { test, expect } from '<headless-browser-runner>';
import AxeBuilder from '<axe-core-binding>';
test('homepage a11y', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'])
.disableRules(['color-contrast']) // only with a documented design-system override
.analyze();
expect(results.violations).toEqual([]);
});
```
- Run axe on every route in CI; fail the build on any violation. Disable a rule only with a documented reason and a tracked issue.
- axe catches: missing alt text, missing form labels, duplicate ids, ARIA mismatches, contrast under 4.5:1 (when not disabled), `<html>` without `lang`, headings out of order, missing landmark roles.
- axe does NOT catch: meaningful alt text (it accepts `alt=""` as valid), correct heading hierarchy (ordering only, not semantic structure), focus order, screen-reader announcement quality, dynamic content updates.
Second-opinion gates:
- pa11y-ci: `pa11y-ci --sitemap https://<host>/sitemap.xml --threshold 0` runs HTML_CodeSniffer against a sitemap (different rule set than axe, high but partial overlap).
- Lighthouse a11y: `lhci autorun --collect.url=https://<staging-host>` catches contrast in computed styles after JS runs. Require `categories:accessibility:score >= 0.95` (target 1.0).
Check: every route has an axe test asserting zero violations; disabled rules are enumerated in a config file with a comment per rule; manual axe in DevTools is in the pre-launch checklist (see pre-launch.md). At least two a11y tools run in CI (axe plus pa11y or Lighthouse), all asserting zero violations or a documented baseline that only decreases over time, never increases.
## Performance Budgets in CI
A performance budget is a number asserted on every PR that fails the merge when exceeded. Without it, every PR is one regression closer to a slow site.
JS/CSS gzipped budget (size-limit). Block merge when exceeded; PR comment shows current vs limit vs delta; use `--why` to name the dependency that pushed it over.
```json
{
"size-limit": [
{ "path": "dist/assets/index-*.js", "limit": "90 KB", "gzip": true },
{ "path": "dist/assets/index-*.css", "limit": "20 KB", "gzip": true },
{ "path": "dist/assets/vendor-*.js", "limit": "120 KB", "gzip": true }
]
}
```
Per-route budget (bundlesize). Stricter than total-app budgets; catches a heavy library imported on a route that did not need it:
```json
{
"bundlesize": [
{ "path": "dist/route-home-*.js", "maxSize": "60 KB", "compression": "gzip" },
{ "path": "dist/route-product-*.js", "maxSize": "80 KB", "compression": "gzip" },
{ "path": "dist/route-checkout-*.js", "maxSize": "100 KB", "compression": "gzip" }
]
}
```
| Budget | Limit (gzipped) | Tool |
|--------|-----------------|------|
| Main index JS | 90 KB | size-limit |
| Main index CSS | 20 KB | size-limit |
| Vendor JS | 120 KB | size-limit |
| Per-route home chunk | 60 KB | bundlesize |
| Per-route product chunk | 80 KB | bundlesize |
| Per-route checkout chunk | 100 KB | bundlesize |
Field-INP gate (lab budgets miss field INP). Query CrUX p75 INP for the production origin, compare to baseline, and fail the deploy on a regression of more than 50ms. Gates on the rolling 28-day CrUX p75: the signal lags 28 days but is the only field-INP gate that needs no private RUM pipeline:
```bash
curl -s "https://<crux-api-host>/runPagespeed?url=https://<origin>&strategy=mobile&key=$PSI_KEY" \
| jq -e '.loadingExperience.metrics.INTERACTION_TO_NEXT_PAINT_MS.percentile <= 200'
```
Check: CI has at least one bundle-size gate (size-limit or bundlesize) and at least one field-perf gate (CrUX query); budgets are defined per route, not just per app; budget changes are reviewed in PRs.
## Type-Only and Lint Gates
Static analysis catches regressions runtime tests miss. Run each as a separate CI job for faster feedback than the bundler.
- Type check: `npx tsc --noEmit --project tsconfig.json` type-checks every file without output; assert zero errors. tsconfig has `strict: true`.
- Strict flags worth enabling, each catching a real bug class: `strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noImplicitOverride`, `noFallthroughCasesInSwitch`. Turn on new strict flags one at a time with a clean-up PR per flag.
- Lint beyond types: `npx biome check src/` or `npx eslint --max-warnings 0 src/` treats warnings as errors, catching dead code, hooks-rules violations, accessibility lint, framework-specific traps, import-cycle warnings.
Check: type check and lint run in CI; warnings count is monitored and trends down; ignored rules are enumerated in the config with a comment per rule.
## Contract Tests
The frontend-backend contract is a recurring regression source. Two patterns.
Consumer-driven contract: the consumer writes the requests it makes and responses it expects; the provider verifies that contract against its own implementation in its own CI; both publish to a shared broker. When the backend renames a field without updating the contract, its CI fails, so the frontend learns at PR time not deploy time. Requires the backend to run verification in their CI.
```js
provider.addInteraction({
state: 'a user exists',
uponReceiving: 'a request for the user profile',
withRequest: { method: 'GET', path: '/api/users/1' },
willRespondWith: {
status: 200,
body: { id: 1, name: like('Alice'), email: like('alice@example.com') },
},
});
```
Runtime schema validation (lower coordination): declare the response schema (Zod, Valibot, JSON Schema, OpenAPI), validate every response in development, log failures to the error tracker. Catches schema drift in production with attribution:
```js
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
const response = await fetch('/api/users/1');
const parsed = UserSchema.safeParse(await response.json());
if (!parsed.success) reportError({ type: 'schema-mismatch', issues: parsed.error.issues });
```
Check: every consumed API endpoint has either a consumer-driven contract or a runtime schema validation; schema mismatches in production trip an alert (see observability.md).
## lighthouse-ci Wiring
The highest-leverage perf gate. Wire it into the PR loop so a regression blocks the merge.
CI job (run on pull_request after dependency install and build):
```yaml
name: lighthouse-ci
on: pull_request
jobs:
lhci:
runs-on: ubuntu-latest
steps:
- uses: <checkout-step>
- uses: <setup-node-step>
with:
node-version: '20'
- run: npm ci
- run: npm run build
- name: run lhci
uses: <lighthouse-ci-action>
with:
urls: |
https://<staging-host>/
https://<staging-host>/product
https://<staging-host>/checkout
uploadArtifacts: true
temporaryPublicStorage: true
configPath: ./lighthouserc.json
```
- `urls`: one per representative surface, covering home, a content route, and a flow-critical route (checkout, sign-up).
- `uploadArtifacts: true` saves the raw Lighthouse JSON as a build artefact (failures reproducible in review).
- `temporaryPublicStorage: true` posts a public-viewer URL in the PR comment.
- `configPath` points to the assertion config below.
Assertion config. `numberOfRuns: 5` reduces flake; the action reports the median:
```json
{
"ci": {
"collect": { "numberOfRuns": 5, "settings": { "preset": "desktop" } },
"assert": {
"preset": "lighthouse:recommended",
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"categories:accessibility": ["error", { "minScore": 0.95 }],
"categories:best-practices": ["error", { "minScore": 0.95 }],
"categories:seo": ["error", { "minScore": 0.95 }],
"first-contentful-paint": ["error", { "maxNumericValue": 1800 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"interactive": ["warn", { "maxNumericValue": 3500 }]
}
}
}
}
```
The action posts a per-surface score-delta comment so the reviewer sees the trend and fixes regressions in the same PR:
```text
Lighthouse scores
| Category | / | /product | /checkout |
|---------------|---|----------|-----------|
| Performance | 96 (-1) | 94 (-2) | 91 (=) |
| Accessibility | 100 (=) | 100 (=) | 100 (=) |
| Best Practices| 100 (=) | 100 (=) | 100 (=) |
| SEO | 100 (=) | 100 (=) | 100 (=) |
```
Check: every PR shows the Lighthouse summary as a comment within 5 minutes of the build completing; score regressions block the merge; the public-viewer URL is opened during PR review.
## CI realism for a perfect-score pipeline
Keeping the gate green in a real runner needs three discipline rules. The gate must stay hermetic, the variable metric must be floored not pinned, and gates split by feedback cost.
- Hermetic gate (T1-D): when a Lighthouse CI gate runs against a local preview server, do NOT let a self-injected third-party tag into that build, or the gate starts depending on the external network and fails in a locked-down runner. Emit such tags only in the real deploy build (gate the tag on a deploy-only env var). The audit gate stays hermetic; production still gets the tag.
- Performance is the variable category (T3-J): on a shared CI runner (often 2 vCPU) the mobile preset 4x CPU throttle competes for the same cores, depressing and destabilizing Performance, while A11y, Best Practices, and SEO are deterministic. Gate Performance at a floor (e.g. `minScore` 0.9) while holding the other three at 1.0. Do not run more Lighthouse workers than the runner has cores; get wall-clock parallelism from a sliced-URL matrix across separate runners, not many workers on one box.
- Split gates by feedback cost (T3-K): fast deterministic gates (format, lint, type, unit) run per PR; slow gates (full LHCI, visual regression, end-to-end) run on pre-push and on-merge, not on every PR push.
## Testing vs Audit Workflow
Two complementary disciplines, often conflated. Run both: tests are the gate, the audit loop is the polish.
| Property | Testing (pre-merge gate) | Audit (post-build polish loop) |
|----------|--------------------------|--------------------------------|
| When | Every PR | Per deploy or per cadence |
| Output | Pass / fail | Findings list |
| Feedback | Minutes | Hours |
| Scope | Changed code | Entire surface |
| Decision | Block merge | Schedule polish work |
| Coverage | Mechanical | Includes judgement |
- Testing catches: logic regression (unit, integration), style drift (visual), surface-flow break (end-to-end), accessibility violation (axe, Lighthouse a11y), performance-budget breach (size-limit, Lighthouse perf assertion), schema drift (contract).
- Audit catches what fixed-viewport snapshots and lab Lighthouse cannot: real-device rendering and OS font stacks, real-network/real-CPU performance, human screen-reader findings (meaningful alt text, heading semantics), SEO drift (canonical drift, broken structured data, sitemap freshness), cross-browser quirks (Firefox-only, Safari-only), brand polish (motion timing, copy tone, illustration consistency).
A site with tests and no audits ships mechanically correct code that looks shabby; a site with audits and no tests ships polished code that regresses every release.
Check: the release process documents both gates (tests mandatory, audits scheduled); audit findings feed the next sprint as issues, not emergency hotfixes (see audit-workflow.md).
## See Also
- [lighthouse.md](lighthouse.md): the audit-row mapping the lighthouse-ci assertions reference
- [observability.md](observability.md): post-deploy field signals that complement pre-merge tests
- [pre-launch.md](pre-launch.md): the evidence manifest that consumes the CI artefacts
- [audit-workflow.md](audit-workflow.md): the post-build polish loop these gates complement
scripts/check-no-dashes.mjs
#!/usr/bin/env node
// Fails when tracked Markdown files contain U+2014 (em) or U+2013 (en) characters.
// Walks SKILL.md, README.md, CONTRIBUTING.md, CHANGELOG.md, and references/**/*.md.
// Skips fenced code blocks, this script itself, node_modules, and .git.
// ESM, stdlib only. Run: node scripts/check-no-dashes.mjs
import { readFile, readdir, stat } from 'node:fs/promises';
import { join, relative, dirname, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import process from 'node:process';
const HERE = dirname(fileURLToPath(import.meta.url));
const EM = '—';
const EN = '–';
async function findRoot(start) {
let dir = start;
// Walk up looking for package.json (preferred) or .git (fallback for skill repos).
for (;;) {
for (const marker of ['package.json', '.git']) {
try {
await stat(join(dir, marker));
return dir;
} catch { /* keep searching */ }
}
const parent = dirname(dir);
if (parent === dir) throw new Error('repo root not found');
dir = parent;
}
}
async function* walk(dir) {
let entries;
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (e.name === 'node_modules' || e.name === '.git') continue;
const full = join(dir, e.name);
if (e.isDirectory()) yield* walk(full);
else if (e.isFile() && e.name.endsWith('.md')) yield full;
}
}
async function collectFiles(root) {
const out = [];
for (const top of ['SKILL.md', 'README.md', 'CONTRIBUTING.md', 'CHANGELOG.md']) {
try { await stat(join(root, top)); out.push(join(root, top)); } catch { /* missing is fine */ }
}
for await (const p of walk(join(root, 'references'))) out.push(p);
return out;
}
function scan(text) {
const hits = [];
let inFence = false;
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Toggle on any line whose first non-space content is a triple backtick fence.
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
if (inFence) continue;
for (let c = 0; c < line.length; c++) {
const ch = line[c];
if (ch === EM) hits.push({ line: i + 1, col: c + 1, kind: 'EM' });
else if (ch === EN) hits.push({ line: i + 1, col: c + 1, kind: 'EN' });
}
}
return hits;
}
async function main() {
const root = await findRoot(HERE);
const selfRel = relative(root, fileURLToPath(import.meta.url));
const files = (await collectFiles(root)).filter(f => relative(root, f) !== selfRel);
const results = await Promise.all(files.map(async f => ({ file: f, hits: scan(await readFile(f, 'utf8')) })));
let totalHits = 0, filesWithHits = 0;
for (const r of results) {
if (!r.hits.length) continue;
filesWithHits++;
totalHits += r.hits.length;
const rel = relative(root, r.file).split(sep).join('/');
for (const h of r.hits) process.stderr.write(`${rel}:${h.line}:${h.col}: ${h.kind} dash\n`);
}
if (totalHits === 0) {
process.stdout.write(`check-no-dashes: OK (${files.length} files scanned)\n`);
process.exit(0);
}
process.stderr.write(`check-no-dashes: FAIL (${totalHits} hits in ${filesWithHits} files)\n`);
process.exit(1);
}
main().catch(err => { process.stderr.write(`check-no-dashes: ERROR ${err.message}\n`); process.exit(2); });