oh-my-design-cli 1.8.2 → 1.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.ja.md +5 -5
  2. package/README.ko.md +7 -7
  3. package/README.md +7 -7
  4. package/README.zh-TW.md +5 -5
  5. package/agents/omd-master.md +1 -1
  6. package/data/reference-fingerprints.json +964 -4
  7. package/package.json +3 -1
  8. package/skills/omd-feel/SKILL.md +152 -0
  9. package/skills/omd-feel/provenance.md +233 -0
  10. package/skills/omd-feel/reference.md +254 -0
  11. package/skills/omd-harness/SKILL.md +1 -1
  12. package/skills/omd-init/SKILL.md +1 -1
  13. package/skills/omd-reference-capture/SKILL.md +1 -1
  14. package/web/references/591/DESIGN.md +460 -0
  15. package/web/references/asana/DESIGN.md +485 -0
  16. package/web/references/asos/DESIGN.md +475 -0
  17. package/web/references/bahamut/DESIGN.md +416 -0
  18. package/web/references/bbc/DESIGN.md +439 -0
  19. package/web/references/chunghwa/DESIGN.md +419 -0
  20. package/web/references/databricks/DESIGN.md +467 -0
  21. package/web/references/deliveroo/DESIGN.md +458 -0
  22. package/web/references/doordash/DESIGN.md +429 -0
  23. package/web/references/easywallet/DESIGN.md +449 -0
  24. package/web/references/esunbank/DESIGN.md +445 -0
  25. package/web/references/farfetch/DESIGN.md +436 -0
  26. package/web/references/fubon/DESIGN.md +438 -0
  27. package/web/references/govuk/DESIGN.md +450 -0
  28. package/web/references/hana/DESIGN.md +439 -0
  29. package/web/references/hubspot/DESIGN.md +485 -0
  30. package/web/references/hyundai/DESIGN.md +468 -0
  31. package/web/references/icook/DESIGN.md +432 -0
  32. package/web/references/instacart/DESIGN.md +439 -0
  33. package/web/references/ipassmoney/DESIGN.md +407 -0
  34. package/web/references/kakaopage/DESIGN.md +439 -0
  35. package/web/references/kbpay/DESIGN.md +442 -0
  36. package/web/references/kia/DESIGN.md +411 -0
  37. package/web/references/liner/DESIGN.md +465 -0
  38. package/web/references/monzo/DESIGN.md +461 -0
  39. package/web/references/naverpay/DESIGN.md +478 -0
  40. package/web/references/octopusenergy/DESIGN.md +436 -0
  41. package/web/references/openpoint/DESIGN.md +431 -0
  42. package/web/references/paypal/DESIGN.md +459 -0
  43. package/web/references/reddit/DESIGN.md +537 -0
  44. package/web/references/samsung/DESIGN.md +465 -0
  45. package/web/references/shinhanbank/DESIGN.md +429 -0
  46. package/web/references/skyscanner/DESIGN.md +563 -0
  47. package/web/references/snapchat/DESIGN.md +460 -0
  48. package/web/references/squarespace/DESIGN.md +454 -0
  49. package/web/references/ssg/DESIGN.md +439 -0
  50. package/web/references/starling/DESIGN.md +404 -0
  51. package/web/references/taiwanmobile/DESIGN.md +445 -0
  52. package/web/references/trainline/DESIGN.md +454 -0
  53. package/web/references/zoom/DESIGN.md +457 -0
@@ -0,0 +1,254 @@
1
+ # omd:feel — Quantified Interface-Feel Reference
2
+
3
+ > Machine-generated from a provenance-graded research corpus (2026-06-23). **Do not hand-edit numbers here** — regenerate via the maintainer pipeline (`gen-feel-reference.py`).
4
+ > 17 feel-dimensions · 113 rules. On-demand rulebook the skill consults; the always-loaded cheat-sheet lives in `SKILL.md`, per-rule sources in `provenance.md`.
5
+
6
+ ## Provenance tiers → audit severity
7
+
8
+ Every rule carries a tier. The tier decides how hard to enforce it — this is the core of honestly *quantifying* gut-feel: spec numbers are gates, taste numbers are suggestions.
9
+
10
+ | Badge | Tier | Meaning | APPLY | AUDIT severity |
11
+ |---|---|---|---|---|
12
+ | 🟢 SPEC | spec-authoritative | W3C/WCAG, platform HIG, normative | enforce | **BLOCK** on violation |
13
+ | 🟢 DS | ds-token | committed design-system token | enforce (unless brand overrides) | **BLOCK** |
14
+ | 🟡 CONV | industry-convention | ≥2 independent reputable practitioners converge | apply as default | **WARN** |
15
+ | ⚪ OP | single-opinion | one credible practitioner/blog | offer as suggestion | **FYI** |
16
+ | ⚪ FOLK | folklore-unverified | widely repeated, no traceable basis | offer, disclose tier | **FYI** |
17
+
18
+ **Tier distribution:** SPEC 61 · DS 24 · CONV 19 · OP 7 · FOLK 2
19
+
20
+ > **DESIGN.md always wins.** When a brand `DESIGN.md` token conflicts with a rule here, the brand token is authoritative — these are the defaults for when the brand is *silent*, not overrides.
21
+
22
+ ---
23
+
24
+ ## 1. Motion & Timing (durations)
25
+
26
+ *Duration is not one global number — it scales with the pixel area/distance an element traverses and is quantized to a design-system token ladder. The cross-system convergent band for routine UI is ~150-300ms (default ~200ms), with hovers ≤150ms and full-screen/overlay 300-500ms; exits run ~0.8× their enter. Functional UI motion stays under ~300ms; >400ms reads as sluggish. Named ladders (M3 50ms steps, Carbon 70-700ms, Fluent 50-500ms, Polaris/Primer/Ant) are the authoritative sources; the ~200ms default is a verified synthesis of ≥5 committed token sets.*
27
+
28
+ | Rule | Default | Range | Tier | Applies to | ✗ Anti-pattern | 🔍 machine_check |
29
+ |---|---|---|---|---|---|---|
30
+ | Scale transition duration to the traversed area/distance: tiny state changes get short tokens, full-screen transitions get long tokens — never one global duration. | 200ms for routine UI; ~150ms inline state, ~200ms popover, ~300ms overlay/modal | 50-1000ms (size-dependent); routine UI converges 150-300ms | 🟢 SPEC | Choosing a duration bucket for any transition by element bounding-box area / translate distance (M3 'duration increases as area/traversal increases'; Geist 150/200/300 tiering) | One global duration for everything; a 600ms fade on a checkbox tick or a 100ms slide on a full-screen page transition | Correlate animated element area/translate distance with duration; flag full-viewport transitions <250ms and sub-48px element transitions >300ms; flag a single hardcoded duration reused across all sizes |
31
+ | Quantize every duration to the M3 token scale (50ms steps 50-600, then 700/800/900/1000) — never hand-pick arbitrary ms. | short tokens 50-200ms; medium 250-400ms; long 450-600ms; extra-long 700-1000ms | 50-1000ms in 50ms steps (16 tokens; scale skips 650ms) | 🟢 SPEC | Any UI transition duration in an M3-aligned system; --md-sys-motion-duration-<bucket><n> | Off-grid durations like 220ms, 333ms, 0.18s | Flag any duration not in {50,100,150,200,250,300,350,400,450,500,550,600,700,800,900,1000}ms for M3-aligned code |
32
+ | Keep functional UI animations under ~300ms; aim ~150-200ms for most enter/exit transitions. | 200ms | 150-300ms (NN/g spec range 100-500ms; >400ms reads slow, 500ms+ = 'a real drag') | 🟡 CONV | User-initiated UI transitions: dropdowns, popovers, modals, accordions, selects (not page/route or hero moments) | Durations >300ms feel sluggish (180ms vs 400ms select example); <80ms on visible content moves looks like a jump cut | Flag transition/animation duration >300ms on dropdown/popover/modal/tooltip components |
33
+ | Use the Carbon duration ladder for productive/enterprise UI: 70ms button/toggle, 110ms fade, 150ms small expand, 240ms toast/expansion, 400ms large expansion, 700ms backdrop dim. | fast-01=70, fast-02=110, moderate-01=150, moderate-02=240, slow-01=400, slow-02=700 | 70-700ms | 🟢 DS | IBM Carbon / Carbon-derived UIs mapping duration to element size | One global ~300ms for everything; a 700ms button or a 70ms full-screen modal | Flag durations not in {70,110,150,240,400,700}ms for Carbon-aligned code |
34
+ | Use the Fluent 2 duration ramp (50-500ms in 8 steps): hover ≤100ms, standard controls 150-200ms, dialogs/panels ≥300ms. | ultraFast=50, faster=100, fast=150, normal=200, gentle=250, slow=300, slower=400, ultraSlow=500 | 50-500ms | 🟢 DS | Microsoft Fluent 2-aligned UIs | >500ms for standard UI; using ultraSlow (500ms) for a hover state | Flag durations outside {50,100,150,200,250,300,400,500}ms in Fluent-aligned code; assert overlay/dialog open ≥300ms and hover ≤100ms |
35
+ | Quantize Polaris durations to a 50ms grid 0-500ms (plus a 5000ms loop slot); never use in-between values. | 50ms grid | 0-500ms in 50ms steps (+5000ms loop) | 🟢 DS | Shopify Polaris-aligned UIs | 220ms/333ms off-grid values; 5000ms used for a one-shot transition | Assert every duration % 50 === 0 and ≤500ms (excluding the 5000ms loop token) |
36
+ | Quantize Primer durations to a 100ms grid (with a 50ms half-step) 0-1000ms; keep UI transitions 100-400ms. Primer skips 150/250/350. | 100ms grid | {0,50,100,200,300,400,500,600,700,800,900,1000}ms | 🟢 DS | GitHub Primer-aligned UIs | 250ms/350ms (not in Primer's base scale); transitions >1000ms | Assert duration ∈ {0,50,100,200,300,400,500,600,700,800,900,1000}; flag 150/250/350 as off-system |
37
+ | Collapse Ant Design motion into fast/mid/slow ≈100/200/300ms (computed from motionBase=0 + motionUnit=0.1). | Fast=100ms, Mid=200ms, Slow=300ms | 100-300ms (default theme) | 🟢 DS | Ant Design: Fast=hover/ripple, Mid=default component transitions, Slow=dropdowns/modals/drawers | Component-specific raw-ms durations bypassing the tokens; all three set to the same value | Resolve motionDurationFast/Mid/Slow; assert 0.1/0.2/0.3s default; flag raw ms literals in components |
38
+ | Use 300ms entering / 250ms returning for container-transform transitions; 150ms incoming / 75ms outgoing (linear) for cross-fades. | container-transform 300/250ms; fade 150/75ms · *standard cubic-bezier(0.2,0,0,1) for container; linear for fade* | 75-300ms | 🟢 DS | M3 container-transform (card→detail, FAB→sheet) and fade-through cross-fades | Symmetric enter/return; equal-length cross-fades (muddy double-visible midpoint) | Container-transform: enter=300ms, return=250ms; cross-fade: incoming≈150ms, outgoing≈75ms linear; flag equal durations |
39
+ | Make exit/dismiss/collapse animations shorter than their enter — roughly 0.8× (75-85%). | exit ≈ 0.8 × enter (e.g. 300ms open / 250ms close; MUI 225/195ms) · *enter decelerate / exit accelerate* | exit 0.75-0.85× enter | 🟢 DS | Paired enter/exit of modals, accordions, drawers, dropdowns, toasts | Symmetric enter/exit (exit lingers after the user has mentally moved on); exit slower than enter | For paired enter/exit assert exit_duration < enter_duration (target ratio 0.75-0.85); flag exit ≥ enter |
40
+
41
+ ## 2. Easing & Springs
42
+
43
+ *Direction encodes intent: ease-out/decelerate for elements entering or any user-initiated change (fast start = responsive), ease-in/accelerate for exits, ease-in-out for already-visible morphs, linear only for spinners/progress. Named cubic-beziers come straight from committed DS tokens (M3, Carbon, Fluent, Polaris, Primer, Base Web, Ant, Atlassian). Critical nuance: M3 'emphasized' is a multi-stop PATH/spring curve — cubic-bezier(0.2,0,0,1) is only its approximation and is actually the M3 'standard' token. On Apple, default to springs (response 0.55, damping 0.825, bounce 0) and keep bounce under ~0.4 for functional UI.*
44
+
45
+ | Rule | Default | Range | Tier | Applies to | ✗ Anti-pattern | 🔍 machine_check |
46
+ |---|---|---|---|---|---|---|
47
+ | Default to ease-out/decelerate for elements entering and any user-initiated transition; the fast start reads as instant responsiveness. Never use ease-in for enters. | cubic-bezier(0,0,0.2,1) (Material decelerate) / ease-out family · *ease-out / decelerate* | ease-out family: cubic-bezier(0,0,0.2,1) … (0.19,1,0.22,1) | 🟡 CONV | Dropdowns, modals, popovers, menus opening; any element appearing or user-triggered change | ease-in on enter (slow start feels sluggish); bare CSS `ease` (too weak/symmetric) on directional motion | Flag `ease-in`/slow-start cubic-bezier (first control point y≈0, x>0.3) on entrance; flag bare `ease`/`linear` on non-utility directional motion |
48
+ | Use the M3 standard easing cubic-bezier(0.2,0,0,1) for on-screen moves; standard-decelerate (0,0,0,1) for enters, standard-accelerate (0.3,0,1,1) for exits; emphasized variants for hero motion. | standard=cubic-bezier(0.2,0,0,1); emphasized-decelerate=(0.05,0.7,0.1,1); emphasized-accelerate=(0.3,0,0.8,0.15) · *standard / emphasized split* | 6 named curves (standard, standard-decel/accel, emphasized-decel/accel, linear) | 🟢 SPEC | M3 motion; --md-sys-motion-easing-* tokens (enter decelerates, exit accelerates) | Browser default ease/ease-in-out or one curve for both enter and exit; confusing M3 (0.2,0,0,1) with the legacy M2 standard (0.4,0,0.2,1) | Assert transition-timing-function matches one of the 6 M3 curves; flag bare ease/ease-in/ease-out/linear on non-utility transitions |
49
+ | Treat M3 'emphasized' as a 3-stop spring-like PATH curve, not a single cubic-bezier; cubic-bezier(0.2,0,0,1) is only its approximation (and is actually the 'standard' token). Use CSS linear() or an Android PathInterpolator for true emphasized motion. | emphasized true curve = path 'M0,0 C0.05,0,0.133,0.06,0.166,0.4 C0.208,0.82,0.25,1,1,1'; approximation = cubic-bezier(0.2,0,0,1) · *emphasized (spring/path)* | n/a | 🟢 SPEC | --md-sys-motion-easing-emphasized — default M3 curve for hero/common transitions that begin and end on screen | Claiming emphasized == cubic-bezier(0.2,0,0,1) exactly; using the cheap cubic approximation where the spring tail is the point | For emphasized fidelity, check for a CSS linear() easing with many stops or a PathInterpolator; a plain cubic-bezier(0.2,0,0,1) labeled 'emphasized' is the approximation |
50
+ | Use ease-in-out only for elements already on-screen that change position or shape (morphing) — not for enter/exit. | cubic-bezier(0.4,0,0.2,1) (Material standard) · *ease-in-out / standard* | — | 🟢 DS | On-screen morphs/repositioning: resizing containers, Dynamic-Island reshaping, sliding between two visible positions | ease-in-out on appear/disappear (adds a slow start that hurts perceived responsiveness) | If display/opacity toggles (enter/exit) require ease-out; if only transform/size changes while visible, ease-in-out is acceptable; flag ease-in-out on appear/disappear |
51
+ | Pick easing by intent and lifecycle from your DS token set — productive/practical for dense task UI, expressive/bold for hero moments; entrance curves start at (0,0), exit curves end at x=1. | Carbon productive standard (0.2,0,0.38,0.9), entrance (0,0,0.38,0.9), exit (0.2,0,1,0.9); Fluent decelerateMax (0.1,0.9,0.2,1)/accelerateMax (0.9,0.1,1,0.2); Atlassian bold (0,0.4,0,1) vs practical (0.4,1,0.6,1) · *decelerate=enter / accelerate=exit / standard=move* | DS-specific named curve sets (Carbon 6, Fluent 9, Polaris 5, Primer 5, Base Web 4, Ant 8, Atlassian 4) | 🟢 DS | Any DS-aligned UI; productive=micro/dense, expressive/bold=attention/large surfaces | Linear on UI position changes (mechanical); accelerate curve for an element that appears; bold/sharp curve on a high-frequency hover fade | Verify entrance curves start at (0,0) and exit curves end at x3=1; match coefficients against the active DS curve constants |
52
+ | Reserve overshoot 'back' easings (control points outside 0-1) for playful emphasis only; never on dense/productive UI. | Ant easeOutBack=cubic-bezier(0.12,0.4,0.29,1.46), easeInBack=(0.71,-0.46,0.88,0.6) · *back / overshoot* | control-point y outside [0,1] = overshoot | 🟢 DS | Playful/attention moments (popovers, attention bounces) only | Applying 'Back' overshoot (y>1 or y<0) to routine/dense UI — the bounce reads as unprofessional | Detect cubic-bezier with any control-point y outside [0,1]; assert not on core form/table transitions |
53
+ | On Apple platforms, default UI motion to a spring (response 0.55, dampingFraction 0.825, blendDuration 0); think response+damping, not fixed duration. Pick smooth/snappy/bouncy by intent. | response 0.55, dampingFraction 0.825 · *spring* | response ~0.3-0.6s for UI; dampingFraction 0.7-1.0 (1.0 = no overshoot) | 🟢 SPEC | SwiftUI state-change animations (withAnimation, .animation(.spring())) on iOS 17+ | Replacing the default spring with linear/easeInOut fixed-duration for interactive UI; dampingFraction <0.6 on serious/data UI | Assert .spring( response in [0.3,0.6], dampingFraction in [0.7,1.0] unless intentionally bouncy; flag .linear/.easeInOut(duration:) on interactive gestures |
54
+ | Default spring bounce to 0 (smooth); use up to ~0.15 for liveliness, ~0.3 for noticeable bounce, but stay under ~0.4 for functional UI. In Motion/Framer, set bounce + visualDuration, not raw stiffness/damping/mass. | bounce 0; Motion example visualDuration 0.5, bounce 0.25 · *spring (bounce param)* | bounce 0-0.4 for UI (1.0 = pure oscillation); visualDuration 0.2-0.6s | 🟢 SPEC | SwiftUI 'bounce' param / Motion spring overshoot on sheets, toggles, cards | bounce >0.4 on functional UI (toy-like); hand-tuning stiffness/damping/mass for unpredictable settle; bouncy springs on text/content that should settle calmly | Grep .spring(duration:bounce: / .bouncy( / Motion {bounce}; assert bounce ≤0.4 for non-playful UI; flag damping <0.6 on standard controls |
55
+ | Use Apple-talk practitioner spring tuning by interaction context (single-source reconstruction, not Apple-official): light tap ~damping 0.4/response 0.2, momentum drawers ~0.8/0.3, draggable PiP ~1.0/0.4. | damping 0.4-1.0 / response 0.2-0.4 by interaction · *spring* | damping 0.4 (bouncy tap) to 1.0 (clean-landing drag); response 0.2-0.4s | ⚪ OP | Direct-manipulation components: button feedback, momentum drawers, draggable panels (one-practitioner reconstruction of WWDC18 803) | One global spring for everything; high bounce on a draggable panel that should land cleanly | Inventory each interactive component's spring params; assert draggable-to-target uses damping ~1.0, tap feedback uses ~0.4/response ~0.2s; flag identical spring across semantically different interactions |
56
+
57
+ ## 3. Spacing & Rhythm
58
+
59
+ *Spacing snaps to an 8px base grid (4px half-step), line-heights to a 4px baseline grid; type sizes follow a modular ratio (1.2-1.333 for UI, ≤1.618 for editorial). Grouping reads via proximity: internal padding ≤ external gap. Responsive margins/gutters follow the Material keyline system (16/32dp margins, 16/24dp gutters, 72dp content keyline).*
60
+
61
+ | Rule | Default | Range | Tier | Applies to | ✗ Anti-pattern | 🔍 machine_check |
62
+ |---|---|---|---|---|---|---|
63
+ | Set every margin, padding, gap and dimension to a multiple of 8px (4px allowed as a half-step for icons/small text). | 8px base, 4px half-step (scale 4,8,12,16,24,32,40,48,64) | 4-8px increment | 🟢 SPEC | All layout spacing: margins, padding, gaps, component width/height | Arbitrary one-off values (13px, 17px, 22px); odd bases (5px) that split pixels at 1.5x scaling | Parse margin/padding/gap/width/height; assert value % 4 === 0 (prefer % 8) |
64
+ | Round every line-height (and text-block height) to a multiple of 4px so baselines land on a 4px vertical grid. | line-height ∈ {16,20,24,28,32...} | multiples of 4px (8px preferred where possible) | 🟢 SPEC | Line-height of body and heading text; vertical spacing between text blocks (centered button/list text exempt) | Fractional line-heights resolving to non-4px values (16px × 1.45 = 23.2px), breaking vertical rhythm | Compute used line-height in px per text node; assert (lineHeightPx % 4) === 0 (scope to text blocks, not centered chrome) |
65
+ | Generate font sizes by multiplying a base by a fixed modular ratio rather than picking sizes ad hoc. | 1.25 (major third) for UI | 1.067-1.618 (minor second to golden; 1.333 perfect fourth, 1.5 fifth, 1.618 golden for editorial) | 🟡 CONV | Type scale / heading hierarchy from a base body size (16px × ratio^n); lower ratio for dense UI, higher for display | Hand-picked unrelated sizes (15,18,21,29...); a high ratio (1.5+) for text-dense UI (steps too jumpy) | Sort distinct font-size tokens; assert each consecutive ratio is ~constant (flag scales varying >5%) |
66
+ | Keep space INSIDE a component ≤ the space AROUND it, so grouping reads via Gestalt proximity. | internal padding ≤ external gap | internal typically 0.5×-1× of external gap | 🟡 CONV | Any component with internal padding among siblings (cards in a grid, list items, form-field groups) | Internal padding larger than the gap between components — items feel disconnected while separate components look glued | For a repeated component, compare content padding to instance gap; flag internalPadding > externalGap (smell-test, not invariant) |
67
+ | Use responsive Material body margins (16dp phone → 32dp at small breakpoint, cap 200dp) and gutters (16dp ≥360dp / 24dp ≥600dp); align icon-leading content to the 72dp keyline (icon at 16dp). | margin 16→32dp; gutter 16/24dp; content keyline 72dp | 16-200dp margins; 16-24dp gutters | 🟢 SPEC | Responsive layout grid (M2-era); list items/app bars with a leading icon/avatar | Fixed margins/gutters across all breakpoints; list-item text starting at an arbitrary x not aligned to 72dp | At <600dp assert side margin==16dp & gutter==16dp; at ≥600dp margin==32dp/gutter==24dp; list rows: icon left==16dp, text left==72dp |
68
+
69
+ ## 4. Radius & Shape
70
+
71
+ *Nested rounded corners must be concentric: outer radius = inner radius + padding (so both share one center). This is now an Apple platform shape (ConcentricRectangle, iOS 26). The CSS overflow-clip trick automates it but has weak Safari support.*
72
+
73
+ | Rule | Default | Range | Tier | Applies to | ✗ Anti-pattern | 🔍 machine_check |
74
+ |---|---|---|---|---|---|---|
75
+ | Set a nested element's radius to the parent's radius minus the gap between them: outer = inner + padding (e.g. 20 = 12 + 8). | outer_radius = inner_radius + padding | exact identity; treat as separate surfaces when padding > 24px | 🟢 SPEC | Any rounded element nested inside another rounded container with uniform padding (card-in-card, button-in-toolbar, image-in-frame) | Same radius on outer and inner (inner corner bulges, gap looks uneven); rounding inner more than outer | Assert child.borderRadius === parent.borderRadius − parent.padding (±1px); CSS --nested:calc(var(--radius) - var(--padding)) |
76
+ | Optionally clip the parent's overflow at the padding box so children inherit a correct concentric corner automatically — but keep a calc()-based fallback (weak Safari support). | overflow: clip; overflow-clip-margin: content-box | n/a (declarative) | ⚪ OP | Parent containers cropping children to a rounded shape without per-child radius | Relying on it as the only mechanism in production (Safari support unreliable) | If overflow-clip-margin present, confirm a fallback inner border-radius exists behind @supports |
77
+
78
+ ## 5. Shadow & Depth
79
+
80
+ *Real depth comes from stacked shadow layers (3-6, blur/offset roughly doubling 1/2/4/8/16px) with per-layer alpha lowered as layers grow, a globally consistent offset direction (vertical ≈ 2× horizontal), and negative spread on large layers so the penumbra doesn't balloon into a glow. As elevation rises, blur+offset increase while opacity decreases. Material elevation uses fixed dp levels (M3: 0/1/3/6/8/12dp) expressed via tonal surface-tint + shadow; in dark mode convey elevation by lightening the surface, not darkening shadows. Token ladders (Tailwind, MDC umbra/penumbra/ambient 0.20/0.14/0.12) anchor the numbers.*
81
+
82
+ | Rule | Default | Range | Tier | Applies to | ✗ Anti-pattern | 🔍 machine_check |
83
+ |---|---|---|---|---|---|---|
84
+ | Stack 3-6 box-shadow layers (blur/offset roughly doubling 1/2/4/8/16px) instead of one shadow; lower each layer's alpha as you add layers so total darkness stays ~constant. | 0 1px 1px, 0 2px 2px, 0 4px 4px, 0 8px 8px, 0 16px 16px; ~5 layers @0.12, ~6 @0.11 | 3-6 layers; per-layer alpha ~0.05-0.12 soft (up to ~0.25 tightest contact layer) | 🟡 CONV | Any elevated surface (card, modal, dropdown, popover, raised button) wanting a realistic/soft shadow | A single box-shadow (reads flat/'CSS-default' with one hard falloff); adding layers at the same high alpha (muddy/too-dark) | Count comma-separated box-shadow segments; flag elevated cards/modals with exactly 1 layer; verify blur/offset ~double between layers; sum composited alpha (1-(1-a)^n) stays under ~0.4 for soft tokens |
85
+ | Keep vertical (Y) shadow offset ~2× the horizontal (X) offset and keep that ratio/direction identical site-wide to imply one fixed light source. | Y:X = 2:1 (X often 0) | Y ≈ 2× X; X may be 0 for top-down light | ⚪ OP | Every drop shadow on a page/app — offset direction must be globally consistent | Different elements shadowing in different directions (implies multiple light sources) | Across all box-shadow tokens, flag any whose X/Y sign differs from the dominant quadrant or whose Y/X ratio deviates far from 2:1 |
86
+ | As elevation increases, increase BOTH blur radius and Y-offset and DECREASE per-layer opacity (the three move together); pull large layers in with a negative spread so they don't balloon into a halo. | low ~1-3px @0.7 (sharp) → high ~16-32px @0.2 (diffuse); spread -1px (sm) to -12px (2xl) | offset/blur ~1→32px as opacity ~0.7→0.2; spread -1 to -12px | 🟢 DS | An elevation/shadow-token ramp (sm→2xl) and hover-raise transitions; large-blur layers | Raising an element but keeping the same tight/dark shadow (or making it darker); large blur with 0 spread (glow/halo instead of cast shadow) | Order shadow tokens by elevation; assert monotonic increase in blur+Y-offset and monotonic decrease in alpha; for blur ≥10px assert the 4th (spread) value is negative on directional shadows |
87
+ | Use the Tailwind elevation ramp as concrete two-layer tokens (0.05-0.25 alpha): cards≈md, dropdowns/popovers≈lg, modals≈xl, hero≈2xl. (v3 names; v4 renamed sm→xs etc.) | md: 0 4px 6px -1px /.1, 0 2px 4px -2px /.1; lg: 0 10px 15px -3px /.1, 0 4px 6px -4px /.1; 2xl: 0 25px 50px -12px /.25 | Y-offset 1→25px, blur 2→50px, alpha 0.05→0.25 | 🟢 DS | Tailwind/token-based web UIs needing a ready elevation scale with negative spread | Ad-hoc per-component shadows (token sprawl, mismatched hierarchy) | Diff each elevated element's box-shadow against the Tailwind token set; flag bespoke one-off shadow values |
88
+ | Build Material elevation from three stacked shadows (key-light umbra + penumbra + ambient) at fixed opacities 0.20/0.14/0.12, mapped to the dp level scale. | umbra 0.20, penumbra 0.14, ambient 0.12; M3 levels 0/1/3/6/8/12dp | M2 1dp→24dp triples / M3 levels 0-5 (0/1/3/6/8/12dp) | 🟢 DS | Material-aligned components; card=level1, nav/menus=level2(3dp), FAB/dialog=level3(6dp); dialogs must clearly out-elevate cards | Approximating Material elevation with a single soft shadow; a flat card with the same heavy shadow as a dialog; using level4/5 for resting state | Verify a 'material' elevation utility emits 3 layers @0.20/0.14/0.12; map components to dp tier and assert dialog blur/offset ≫ card; flag elevations not in {0,1,3,6,8,12}dp |
89
+ | In dark mode convey elevation by LIGHTENING the surface with a semi-transparent white overlay (higher = brighter), not by darkening shadows. M3 expresses elevation via a tonal surface-tint overlay + shadow. | white overlay per dp: 1dp 5%, 2dp 7%, 3dp 8%, 4dp 9%, 6dp 11%, 8dp 12%, 12dp 14%, 16dp 15%, 24dp 16% over #121212 | 0% → 16% white overlay across 0dp → 24dp | 🟢 SPEC | Dark-theme surfaces (cards, dialogs, sheets, menus) on near-black backgrounds | Dark drop shadows on near-black (invisible, reads flat); reusing light-mode shadow tokens; pure shadow-only elevation with identical surface fill at every level | In dark theme assert elevated surfaces get a lighter computed background than the page scaling with elevation; flag dark-bg cards whose only depth cue is a low-contrast dark box-shadow |
90
+ | Tint shadows with the background's hue at reduced lightness instead of pure black/grey; on colored surfaces a desaturated black reads as dull grey film. | hsl(220 60% 50% / alpha) instead of hsl(0 0% 0% / alpha) | match surrounding hue; saturation ~50-60%, lightness ~40-50%, low alpha | ⚪ OP | Drop shadows on colored/branded surfaces and any non-pure-white background | rgba(0,0,0,0.x) on every shadow regardless of theme (dull grey haze over colored bg) | Flag pure-black/grey (saturation 0 / r=g=b) shadows on elements whose background is chromatic; prefer shadow hue within ~30° of background hue |
91
+ | Add a 1px inset hairline (~10% opacity, black light / white dark, outline-offset:-1px) on images/media so light-on-light media doesn't bleed into the page. NOTE: the 10% value is folklore — the technique is sound but no authoritative source pins the number. | outline: 1px solid rgba(0,0,0,0.1); outline-offset: -1px (rgba(255,255,255,0.1) dark) | 1px @ 8-12% opacity | ⚪ FOLK | User images, thumbnails, avatars, og/preview tiles on same-tone backgrounds | A white-background image flush on a white card with no edge (merges into surface); positive outline-offset shifting layout; tinted/accent outline ('dirt on the edge') | For <img>/media on a background within ~10% lightness, flag those lacking a 1px border or inset hairline at ~0.08-0.12 alpha with outline-offset:-1px |
92
+
93
+ ## 6. Typography
94
+
95
+ *Body measure ~66ch (45-75 comfort, ≤90 hard cap) set in font-relative units; line-height ~1.5-1.6 body / ~1.1 headings (longer measure → more leading). Tracking scales inversely with size: negative on display (−0.02 to −0.03em), ~0 body, positive on all-caps (+0.05 to +0.1em); Inter ships a precise Dynamic Metrics formula. Keep font-optical-sizing:auto on opsz fonts, tabular-nums on changing/aligned numbers, balance on headings / pretty on body. WCAG 1.4.12 text-spacing floors (1.5/0.12/0.16/2.0×) must not break layout.*
96
+
97
+ | Rule | Default | Range | Tier | Applies to | ✗ Anti-pattern | 🔍 machine_check |
98
+ |---|---|---|---|---|---|---|
99
+ | Never break layout when users override spacing to the WCAG 1.4.12 floors: line-height 1.5×, letter 0.12×, word 0.16×, paragraph 2× font-size. | 1.5 / 0.12 / 0.16 / 2.0 (× font-size) | line-height ≥1.5em, letter ≥0.12em, word ≥0.16em, paragraph ≥2em | 🟢 SPEC | All text content — accessibility minimums that user stylesheets/bookmarklets may force | Fixed-height text containers, overflow:hidden on text, or absolute px line-heights that clip/overlap at these values (a WCAG 2.1 AA failure) | Apply the WCAG bookmarklet (line-height:1.5; letter-spacing:0.12em; word-spacing:0.16em; p margin:2em); assert no clipped/overlapping text and no horizontal scroll |
100
+ | Constrain body measure to ~66ch (45-75 comfort band, hard cap ~90ch) and set max-width in a font-relative unit (ch/em), not px, so measure stays correct across font-size and zoom. | max-width: ~66ch | 45-75ch classic (66 ideal); 45-90ch Butterick outer bound; ≈33-40em | 🟢 SPEC | Body copy / running prose blocks on desktop | Full-bleed paragraphs (100+ chars); below ~45ch (choppy); max-width in px (measure drifts at zoom/large font) | characters-per-line ≈ content-width / (0.5 × font-size); flag prose containers without a ch/em max-width or whose effective measure exceeds 90ch; flag px max-width on text blocks |
101
+ | Set body line-height ~1.5-1.6 unitless, headings ~1.1; longer measures need more leading, mobile/narrow less. | 1.5 body / 1.1 heading | body 1.5-1.6 (1.3-1.45 mobile narrow); headings 1.1-1.3 | ⚪ OP | Body line-height at 60-80ch; heading line-height for display text set 1-2 lines | One global line-height:1.5 on everything (gappy headings, slightly tight wide measures); unitless <1.4 on body or <1.1 on headings (ascender/descender collision) | Body should resolve to 1.45-1.65×, h1-h3 to 1.0-1.3×; use unitless; flag headings ≥1.5 or body <1.4 |
102
+ | Tighten letter-spacing as size grows: −0.02 to −0.03em for display (>~60px), ~0 body; inversely, ALL-CAPS labels want POSITIVE tracking ~+0.05 to +0.1em. | display −0.02 to −0.03em; body ~0; all-caps +0.05 to +0.1em | 0 to −0.03em display; +0.04 to +0.12em caps (sources vary 5-20%) | 🟡 CONV | Large display/hero headings (~40px+) and ALL-CAPS/eyebrow labels | Large headings at default 0 tracking (loose/gappy); negative tracking on body/small text (kills legibility); all-caps at 0 tracking (cramped) | Audit letter-spacing by role: display (≥40px) −0.01 to −0.03em; body ~0; uppercase ≥+0.04em; flag body/small text <−0.01em or large headings at 0 or caps ≤0 |
103
+ | For Inter, compute letter-spacing from size with the Dynamic Metrics curve instead of one value: tracking = −0.0223 + 0.185·e^(−0.1745·size_px). | tracking = −0.0223 + 0.185·e^(−0.1745·z) em | a=−0.0223, b=0.185, c=−0.1745; ~+0.01em@12px → ~0@16-20px → −0.0223em asymptote | 🟢 DS | Inter / Inter-derived UI type | Hardcoding one letter-spacing for all Inter sizes (small too tight, display not tight enough); blindly adding spacing — Inter ships correct at default 0 | For each Inter role compute expected = −0.0223 + 0.185*exp(−0.1745*fontSizePx) and assert applied em is within ±0.003em |
104
+ | Apply text-wrap:balance only to headings/short text (≤6 lines Chromium, ≤10 Firefox); use text-wrap:pretty on body to kill last-line orphans (Chromium refines last 4 lines). Don't substitute one for the other. | balance=headings, pretty=body | balance ≤6 (Chromium)/≤10 (FF) lines; pretty = last 4 lines (Chromium) | 🟢 SPEC | balance→h1-h6/blockquotes/titles; pretty→body paragraphs/captions/descriptions | balance on body/`p`/`*` (no-op past the line cap, pays O(n) balancing cost); expecting pretty to balance whole headings; manual   orphan hacks | Flag `text-wrap: balance` on p/body/* or elements with computed line count >6; pair balance with heading selectors only and pretty with body/prose |
105
+ | Keep font-optical-sizing:auto (default) on variable fonts with an opsz axis so glyphs re-shape per rendered size; only override opsz when art-directing. | auto | auto \| none, or manual opsz (e.g. 14-144) | 🟢 SPEC | Variable fonts exposing an opsz axis (Inter, Roboto Flex, Newsreader, Source Serif) | font-optical-sizing:none on an opsz font (display looks clumsy, body fragile); manual font-variation-settings 'opsz' contradicting actual font-size | If the font has an opsz axis, confirm computed font-optical-sizing is auto and no font-variation-settings override pins opsz to a mismatched value |
106
+ | Use font-variant-numeric:tabular-nums for numbers that change in place or align in columns (timers, counters, tables, prices, numeric inputs); leave proportional for prose. | tabular-nums (OpenType tnum) | tabular-nums vs proportional (default) | 🟢 SPEC | Tables/columns of figures, prices, balances, counters, timers, analytics cards, numeric <input> | Proportional digits on a live timer/counter (layout wiggles as 1 vs 8 widths change); tabular-nums forced on body prose; monospace-font hacks instead of tnum | For any number that updates on a timer or aligns vertically, assert tabular-nums; verify the font ships tnum (else it silently no-ops) |
107
+ | Keep body and label text at least 11pt (iOS) / pass color-contrast at size; apply -webkit-font-smoothing:antialiased once at the root for macOS crispness (no-op elsewhere). | ≥11pt body; -webkit-font-smoothing: antialiased at root (macOS only) | 11pt minimum (larger via Dynamic Type) | 🟢 SPEC | iOS on-screen text; macOS/WebKit rendering (esp. light-on-dark) | Sub-11pt captions forcing zoom; fixed sizes ignoring Dynamic Type; believing antialiased 'fixes' fonts on Windows/Linux (zero effect); scattering antialiased per-element | Assert text style computed size ≥11pt; antialiased set at root/body not scattered nodes |
108
+
109
+ ## 7. Color & Contrast
110
+
111
+ *Hard accessibility floors: body text 4.5:1 (3:1 large ≥24px / ≥18.67px bold), AAA 7:1; non-text/UI 3:1; focus indicators 3:1 change-of-contrast on a ≥2px perimeter; never round up (2.999 fails). Semantic hues are a cross-DS convention (red=error/green=success/amber=warning/blue=info) and must never carry meaning by hue alone (WCAG 1.4.1). Disabled content 38% / containers 12% and exempt from contrast. APCA (Lc thresholds) is a draft WCAG-3 method, not yet binding.*
112
+
113
+ | Rule | Default | Range | Tier | Applies to | ✗ Anti-pattern | 🔍 machine_check |
114
+ |---|---|---|---|---|---|---|
115
+ | Give body text ≥4.5:1 contrast; large text (≥24px or ≥18.67px bold) may drop to 3:1; AAA pushes to 7:1 / 4.5:1. Never round before comparing. | 4.5:1 (normal) / 3:1 (large) AA; 7:1 / 4.5:1 AAA | 4.5:1 to 21:1 | 🟢 SPEC | Normal/body text and large/display text (size+weight gate, not just ratio) | Light-gray-on-white body (#999/#fff=2.85:1); applying the 3:1 large allowance to 16px semibold (not large); claiming 'AAA' at 4.5:1 body | Compute ratio = (L1+0.05)/(L2+0.05); text <24px (or <18.67px bold) require ≥4.5; ≥24px (or ≥18.67px bold) require ≥3.0; do not round (2.999 fails 3.0) |
116
+ | Give UI component boundaries, state borders, and meaningful icons/graphics ≥3:1 contrast against adjacent colors. | 3:1 | 3:1 minimum | 🟢 SPEC | Button/input borders, toggle/checkbox outlines, focus/selected indicators, meaningful icons, essential chart parts (not decorative graphics/logos) | A 1px #e0e0e0 input border on #fff (~1.3:1, invisible); pale-gray icon-only buttons | For each control boundary/icon compute ratio vs immediately adjacent color; flag <3.0; exempt [disabled]/aria-disabled |
117
+ | Make the keyboard focus indicator ≥2px thick covering the full perimeter, with ≥3:1 change-of-contrast between the same pixels focused vs unfocused; add ~2px outline-offset so it lifts off the edge. | 2px perimeter, 3:1 change-of-contrast, ~2px offset | thickness ≥2px (area = 4h+4w); 3:1; offset 1-3px | 🟢 SPEC | Keyboard focus indicators on all interactive components (WCAG 2.2 SC 2.4.13 Focus Appearance, AAA) | outline:none with no replacement; a 1px ring; a focus color barely differing from the resting border; indicator clipped by overflow | On :focus-visible assert effective thickness ≥2px and area ≥ a 2px perimeter; change-of-contrast on the same pixels focused-vs-unfocused ≥3.0; flag outline:none without compensating shadow |
118
+ | Render M3 interaction feedback as a translucent state-layer overlay (hover 8%, focus 10%, pressed 10%, dragged 16%) tinted with the content's 'on-' color, not a swapped background. (Legacy mdui/M2 use focus/pressed 12% — prefer M3 values.) | hover 8%, focus 10%, pressed 10%, dragged 16%; color = content on-color | 8-16% opacity (M2 legacy 12% focus/pressed) | 🟢 SPEC | Hover/focus/pressed/dragged feedback on M3 interactive components (buttons, list items, chips); overlay over container | Hardcoding a hex per state; hover darker than focus; >16% (looks selected/active); universal rgba(0,0,0,0.08) regardless of theme; black overlay on dark / white on light | Inspect state-layer opacity on :hover/:focus-visible/:active (0.08/0.10/0.10/0.16); assert color equals content on-color at the state opacity; flag opaque background swaps and literal black/white overlays |
119
+ | Render disabled content at 38% opacity, disabled containers at 12%, and exempt disabled controls from contrast checks; never add hover/pointer feedback to them. | 38% content / 12% container | content 38% / container 12% (rule-of-thumb <40%) | 🟢 DS | Disabled buttons, inputs, menu items (label/icon 38% of on-color, fill 12%) | A disabled control that still looks fully clickable; over-dimming below ~38% (unreadable); enforcing 4.5:1/3:1 on inactive components (WCAG exempts them) | For [disabled]/aria-disabled assert content opacity ≈0.38, container ≈0.12, no hover state-layer, cursor not-allowed; do NOT enforce contrast |
120
+ | Dim the background behind modals/dialogs/sheets with a scrim of black at ~32% opacity (Material); systems vary 30-60%. | #000 @ 32% | 32% (Material); 25-60% across systems | 🟢 DS | Scrim behind modal dialogs, modal bottom sheets, nav drawers | No scrim (modal floats with no separation); near-opaque 80%+ scrim (loses context); scrim that doesn't block clicks beneath | When a modal is open assert a viewport-covering overlay with rgba(0,0,0,~0.32) and pointer-events blocking content behind |
121
+ | Map status meaning to conventional hues (red=error/destructive, green=success, amber=warning, blue=info) and never carry meaning by hue alone — pair with icon/text. | red/green/amber/blue + non-color cue | hue family fixed; shade chosen to hit 4.5:1 text / 3:1 non-text | 🟡 CONV | Status/feedback UI; semantic tokens color.error/success/warning/info | Green delete button or red 'success' toast; status shown only by color (fails color-blind users / WCAG 1.4.1) | Assert error→red, success→green, warning→amber, info→blue; assert every color-coded status also carries an icon/label/shape; each semantic color meets 4.5:1 (text)/3:1 (non-text) |
122
+ | Under APCA (WCAG 3 draft, NOT yet binding) target Lc 75 body min / Lc 90 preferred / Lc 60 non-body / Lc 45 headline / Lc 30 min text / Lc 15 non-text, gated by font size+weight. | Lc 75 (body min) / Lc 90 (preferred) | Lc 15→30→45→60→75→90 (scale 0 to ~106); polarity-aware, signed | 🟢 SPEC | Perceptual-contrast evaluation tied to font size+weight (APCA's own published method; draft candidate for WCAG 3, not a conformance requirement) | Using a single threshold regardless of size/weight; treating Lc like a WCAG-2 ratio (different scale); citing APCA as a binding standard | Run APCA (apca-w3) for signed Lc; map \|Lc\| against the size/weight lookup (≥75 for 18px/400 body, ≥90 preferred); recompute on polarity swap |
123
+
124
+ ## 8. States & Feedback (microinteractions)
125
+
126
+ *Design every data component for five states (empty/loading/error/content/skeleton), not just the happy path. Press gives instant tactile feedback (scale 0.95-0.97 over ~150ms ease-out, interruptible via CSS transition). Contextual icon swaps cross-fade with a no-bounce spring. Optimistic UI updates instantly and rolls back on error (low-risk actions only). Hover panels and tooltips use intent delays. Use CSS transitions (interruptible/retargetable) for state changes, @keyframes only for one-shot sequences.*
127
+
128
+ | Rule | Default | Range | Tier | Applies to | ✗ Anti-pattern | 🔍 machine_check |
129
+ |---|---|---|---|---|---|---|
130
+ | Design every data-bearing component for all five states — empty, loading (skeleton), error, content/success, and ideal/populated — with a CTA in empty and a retry in error. | 5 states (empty / loading / error / content / skeleton) | — | 🟡 CONV | Any component that fetches/holds variable data: lists, tables, dashboards, search results, feeds, forms | Shipping only the populated state; blank screen on empty (looks broken); raw error stack; layout collapse while loading; empty states with no next-step | Assert distinct render branches for isLoading/isError/isEmpty/hasData; empty branch includes a CTA, error branch includes a retry affordance |
131
+ | On :active press, scale the control to ~0.95-0.97 over ~150ms ease-out for tactile feedback, then spring back; drive it with a CSS transition so an early release retargets smoothly. | scale(0.96-0.97), 150ms, ease-out · *ease-out* | 0.95-0.98 (Tailwind active:scale-95 = 0.95); duration 100-160ms | 🟢 DS | :active/pressed on buttons, cards, tappable tiles | Scaling below 0.90 (button collapses); no transition (jumpy); scale on :hover instead of :active; opacity-dim instead of scale; @keyframes that restart on early release | Inspect :active for transform:scale(N) with 0.95≤N≤0.98, transition-property includes transform, duration ≤150ms, bound to :active not :hover, driven by transition not animation |
132
+ | Start entrance scale from ~0.9-0.96 (not 0), fade opacity in, and set transform-origin to the anchor so popovers/dropdowns scale FROM their trigger (use --radix-*-transform-origin). | scale ~0.9-0.96 → 1 + opacity; transform-origin = anchor · *ease-out* | 0.8 (Rauno, more motion) to 0.96 (Emil, subtle); never scale(0) | ⚪ OP | Modal/popover/card/toast entrance; anchored overlays (dropdowns, menus, tooltips, command palettes) | scale(0)→scale(1) ('looks cheap'/pops); default transform-origin:center on anchored overlays (appears 'out of nowhere') | Enter keyframe starting transform scale in [0.8,0.97] (flag <0.8 or 0); for anchored overlays verify transform-origin is set (often --radix-*-content-transform-origin), not default center |
133
+ | Animate a contextual icon swap (copy→check) with scale 0.25→1 + opacity 0→1 + blur 4px→0 on a no-bounce spring (~0.3s, bounce 0); keep both variants in the DOM (one absolutely positioned) for a cross-fade. | scale 0.25→1, opacity 0→1, blur 4px→0, spring duration 0.3 bounce 0 · *spring (bounce 0)* | never start scale at 0.5/0.6; bounce must be 0 | 🟡 CONV | Contextual icon state swap (copy/check, play/pause, expand/collapse glyphs) | Hard cut between icons; bounce >0; starting scale at 0.5/0.6 (too large, no emergence); unmounting the outgoing icon (kills cross-fade) | Icon swap animates scale ~0.25→1, opacity 0→1, blur 4px→0; spring duration ~0.3s bounce 0; both variants present with one position:absolute |
134
+ | Make animations interruptible and retargetable — a new target mid-flight redirects from the current position preserving velocity; use CSS transitions/springs (which retarget) for re-triggered UI, @keyframes only for one-shot sequences. | transitions/springs for interactive state; @keyframes for one-shot | — | 🟢 SPEC | Hover, toggles tapped rapidly, drag handoff, sliders, gesture-driven UI; sheets/drawers/PiP a user can touch mid-animation | @keyframes on toasts/toggles/hover (re-trigger restarts from frame 0, visible jump); animations that must finish before accepting the next touch; timer/setTimeout-gated transitions | Re-trigger mid-flight and assert no position discontinuity; prefer spring/useSpring or transition over fixed-duration tween on gesture-linked values; flag CSS where re-trigger restarts keyframes; prefer UIViewPropertyAnimator over UIView.animate on iOS |
135
+ | For low-risk likely-to-succeed mutations, update the UI immediately (optimistic) and reconcile/rollback on failure with an explicit message; assign request identity so only the latest commits; never for payments/deletes/irreversible actions. | 0ms perceived latency + mandatory rollback path | instant until server reconciliation | 🟢 SPEC | Likes/favorites, reorder, toggles, add-to-list, inline edit (NOT payments, account deletion, permission changes) | Optimistic update with no rollback (silent divergence); optimism on irreversible/high-stakes actions; committing stale out-of-order responses; blocking spinner for an optimizable action | Local state updated before the await with a catch/onError reverting to a captured snapshot AND a toast; each mutation carries a request id so only the latest commits; flag optimistic patterns on destructive/payment endpoints |
136
+ | Delay hover-triggered panels by a short intent window (~100ms, 6px sensitivity) and keep them open a ~300ms grace period after the cursor leaves; for mega-menus use a cursor→submenu 'safe triangle' (tolerance ~75px), not a fixed timeout. | open ~100ms / close grace ~300ms / safe-triangle tolerance 75px | open 100-300ms; close 300-500ms; tolerance 60-100px | 🟢 DS | Mouse-hover dropdowns/tooltips/mega-menus on desktop | 0ms open (every menu flickers as the cursor crosses); instant close on mouseleave (panel vanishes in the gap); switching submenu the instant the cursor leaves the row band ('the diagonal problem'); >300ms open (laggy) | Assert a setTimeout/poll gate before show() (~100ms) and a cancel-on-re-enter close timer (~300ms); for mega-menus assert slope-vs-triangle test (tolerance 75) instead of plain row mouseenter; skip delay+animation for subsequent grouped tooltips (transition-duration 0ms) |
137
+ | Use the three standard haptic generators by meaning (iOS): selection for value changes, impact (light/medium/heavy/soft/rigid) for collisions/boundaries, notification (success/warning/error) for outcomes — never decoratively, always with an on-screen change. | 3 generator classes / 9 patterns (1 selection + 5 impact + 3 notification) | selection ×1, impact ×5, notification ×3 | 🟢 SPEC | iOS/iPadOS haptic feedback tied to UI events (pickers, toggles snapping, action results) | Firing impact-heavy on every tap; notification-success for a non-completion event; haptics with no accompanying visual change; overuse (users disable) | Assert UISelectionFeedbackGenerator/UIImpactFeedbackGenerator(style:)/UINotificationFeedbackGenerator(.success/.warning/.error) match event semantics; flag impact(.heavy) on routine taps or haptics with no visual/audio change |
138
+ | Auto-dismiss an actionless toast/snackbar after 4-10s (Material guideline); if it carries an action, keep it until the user acts or dismisses. NOTE: Android's LENGTH_LONG implementation default is ~2.75s, below the 4s guideline. | 4-10s (guideline); action snackbars persist (LENGTH_INDEFINITE) | 4000-10000ms guideline (Android impl LENGTH_SHORT 1500 / LENGTH_LONG 2750ms) | 🟡 CONV | Toast/snackbar/transient bottom notification without a required action | Dismissing <4s (can't read/reach); persisting forever for a no-action toast; auto-dismissing a message containing the only undo/retry action | Assert 4000≤timeout≤10000 when no action button present; assert no auto-timeout when a button/link role=button exists in the toast |
139
+
140
+ ## 9. Targets & Pointer
141
+
142
+ *Hit areas: 44pt (Apple HIG, also WCAG AAA 44px), 48dp (Material/Android ≈9mm), 24px floor (WCAG 2.2 AA SC 2.5.8 with a 24px-spacing exception) — pad the touch area beyond the visible glyph and keep ≥8pt/8dp between targets. The physical rationale is the ~9-10mm fingertip pad. Fitts's law (MT = a+b·log2(D/W+1)) says bigger+closer is faster and screen edges/corners are effectively infinite-width. Hick's law says decision time grows with log2(n+1). Gesture timings come from platform constants (long-press 400-500ms, touch-slop 8dp, tap 100ms / double-tap 300ms). Cursors must match action and pair with a non-cursor affordance (touch shows no cursor). Every drag needs a single-pointer alternative (WCAG 2.5.7).*
143
+
144
+ | Rule | Default | Range | Tier | Applies to | ✗ Anti-pattern | 🔍 machine_check |
145
+ |---|---|---|---|---|---|---|
146
+ | Give every interactive control a hit area ≥44×44pt (Apple/WCAG AAA), ≥48×48dp (Material/Android ≈9mm), and never below the 24×24 CSS px WCAG 2.2 AA floor — padding the touch area beyond the visible glyph. | 44pt (iOS/WCAG AAA), 48dp (Material), 24px (WCAG AA min) | 24px (AA) → 44pt/44px (AAA/Apple) → 48dp (Material) | 🟢 SPEC | All tappable controls (buttons, icon buttons, tab items, toggles, list affordances, carousel dots, swipe handles); visible icon may be 24dp inside a padded 48dp hit area | Sizing the touch target to the visible icon (16-24px); a 24px-looking icon assumed finger-tappable; packing sub-24px targets edge-to-edge | Compute each interactive element's hit box incl. padding/pseudo-expanders; flag <24×24px (AA fail), warn <44px; on touch contexts flag <44/48; verify ≥8px/8dp spacing to neighbors |
147
+ | Make any pointer target ≥24×24 CSS px, OR if smaller keep undersized targets 24px apart (24px-diameter circles centered on each must not intersect); inline text links and UA-controlled sizes exempt. | 24×24px or 24px center-to-center clearance | 24-44px | 🟢 SPEC | Any clickable/tappable web target (WCAG 2.2 SC 2.5.8, Level AA) | 16px icon buttons packed edge-to-edge; an 18px close 'X'; pagination dots 12px apart | rect=getBoundingClientRect(); fail if (w<24\|\|h<24) AND no sibling-target gap ≥24px; exempt inline text links and UA-controlled elements |
148
+ | Leave at least 8pt/8dp between the edges of adjacent tappable controls; prefer 12-16pt when layout allows. | 8pt/8dp minimum (12-16pt recommended) | 8-16pt | 🟢 SPEC | Edge-to-edge gap between adjacent tappable controls, especially icon buttons in toolbars/chip rows | Two targets butting directly (0pt gap) so a fingertip lands on the wrong one; dense icon toolbars with no separation | For adjacent sibling interactive elements assert edge gap ≥8px/8dp; warn if 8≤gap<12; spacing on an 8pt grid (gap % 8 or % 4 == 0) |
149
+ | On visionOS give interactive elements a 60×60pt hit region (visible control may stay 44pt with padding); ≈2.5° angular / ~4.4cm at 1m. | 60×60pt hit region (visual ~44pt) | 60pt min hit area; visual 44pt; ~2.5° angular | 🟢 SPEC | visionOS interactive elements selected by eyes/pointer/fingertip/remote | Packing eye-targetable controls tighter than 60pt center-to-center; using the iOS 44pt region unchanged on visionOS | Assert each interactive element's tappable bounds (element+spacer) ≥60pt both axes; allow a 44pt rendered control with ≥8pt padding; for fixed-scale 3D verify angular size ≥~2.5° |
150
+ | Cut pointing time by making targets bigger and/or closer (Fitts: MT = a + b·log2(D/W+1)); pin high-frequency global targets to a screen edge/corner where they behave as infinitely wide. | MT = a + b·log2(D/W+1); b ~100-150 ms/bit (mouse); edge=∞W one axis, corner=∞W two axes | ID in bits; 0px overshoot margin at a flush edge | 🟢 SPEC | Predicting/optimizing acquisition time for any pointing action; persistent global controls (menu bar, dock, window close) | Placing the primary CTA far from the cursor's origin; tiny targets at the end of long travel; floating a frequently-used control a few px in from the edge (breaks the 'slam to corner' affordance) | Compute ID = log2(D/W+1) from measured px; flag primary actions with high D AND small W; assert high-use global targets are flush to a boundary (rect.left≤0 etc.), not inset |
151
+ | Reduce decision time by limiting/grouping choices — reaction time grows with log2(n+1), not linearly (Hick-Hyman); chunking lowers effective n. Does NOT apply to sorted/searchable lists users already know. | RT = a + b·log2(n+1); b ≈ 0.155 s/bit (~150ms, empirical not fixed) | b empirically fit (~150ms/bit conventional) | 🟡 CONV | Time to choose among n equiprobable simultaneously-visible options (menus, nav, settings, toolbars) | A flat 30-item nav with no grouping; a settings screen dumping every toggle at one level | Count sibling actionable items at one decision level (nav>a, [role=menuitem]); flag high n with no grouping (no fieldset/section/optgroup/submenu); estimate RT delta via log2(n+1) |
152
+ | Use platform gesture timing constants: long-press ~500ms (Android 400 / iOS-RN 500), don't start a drag until past ~8dp touch-slop (~4px mouse), resolve a tap at ~100ms and reserve ~300ms for double-tap. | long-press 500ms; touch-slop 8dp; tap 100ms / double-tap 300ms | long-press 400-500ms; slop 4-22px; tap 100 / double-tap 300ms | 🟢 DS | Press-and-hold (context menus, reorder, peek); tap-vs-drag disambiguation; tap/double-tap resolution | Long-press <300ms (collides with a slow tap) or >1s (feels broken); starting a drag on the first pixel (finger jitter → accidental drags); the legacy 300ms mobile-web tap delay (fix with viewport meta / touch-action:manipulation) | Assert UILongPressGestureRecognizer.minimumPressDuration ~0.5 (or Android getLongPressTimeout 400), within [0.3,0.8]; dragstart fires only after hypot(dx,dy) > ~4-8px; ensure viewport meta / touch-action:manipulation removes the 300ms tap delay |
153
+ | Don't override reserved system edge gestures (Home indicator, screen-edge back, Control/Notification Center); keep custom edge interactions out of those regions. | screen-edge regions reserved by system | avoid bottom edge (Home) / left-edge back zone / top edges (~20pt detection heuristic) | 🟢 SPEC | Custom swipe/pan gestures starting at screen edges on iOS/iPadOS | A custom bottom-edge swipe fighting the Home gesture; a left-edge pan breaking interactive pop/back; requiring a deferred-system-gesture edge for a core action | Flag custom edge-pan recognizers anchored within ~20pt of screen edges (esp. bottom/left); verify interactivePopGestureRecognizer is not disabled without a replacement |
154
+ | Every drag interaction must also work via a single tap/click without dragging (WCAG 2.5.7); keyboard support alone does NOT satisfy it. | non-drag pointer alternative required | — | 🟢 SPEC | Any author-built drag (sliders, reorderable lists, kanban, map pan, color pickers); WCAG 2.2 SC 2.5.7 Level AA | A reorder list that ONLY works by drag handle; a slider with no track-click and no numeric input; a kanban with drag-only card moves | For each draggable (draggable=true, onDragStart, role=slider, .dnd-*) assert a co-located click/tap control exists (button, track-click handler, input[type=number]); flag drag-only widgets |
155
+ | Set cursor:pointer on actually-clickable controls (and nowhere else), text I-beam on editable/selectable text, grab→grabbing on drag, not-allowed on disabled, zoom-in/out, crosshair for precision; pick one button-cursor policy project-wide; pair every cursor with a non-cursor affordance (touch shows no cursor). | pointer (clickable), text (editable), grab/grabbing (drag), not-allowed (disabled), zoom-in/out, crosshair (precision) | CSS Basic UI L4 cursor keywords | 🟢 SPEC | All interactive/editable/draggable/disabled controls; CSS cursor property | cursor:pointer on non-interactive containers (false affordance); a role=button div left as default arrow; pointer-events:none WITH cursor:not-allowed (cursor never renders); mixing button-cursor policies; relying on cursor alone (invisible on touch) | Assert a[href]/button/[role=button] resolve to pointer; flag pointer on elements with no handler/href/role; flag pointer-events:none + cursor:not-allowed on the same selector; assert every interactive element has a non-cursor affordance (hover/focus style, underline, chrome) |
156
+
157
+ ## 10. Scroll & Gesture
158
+
159
+ *Boundaries use elastic rubberbanding (pow(offset,~0.7)); momentum follows exponential decay with the platform deceleration constant (0.998 normal / 0.99 fast), and snap/dismiss decisions project the fling's resting position from release velocity, not raw displacement. Use scroll-snap (proximity for long content, mandatory+snap-stop:always for carousels), scroll-padding-top for fixed headers, overscroll-behavior:contain to stop scroll-chaining, passive listeners + touch-action over preventDefault, IntersectionObserver/scroll-driven timelines over scroll-event JS, and safe-area-inset env() for notch/home-indicator. Gate smooth-scroll behind reduced-motion.*
160
+
161
+ | Rule | Default | Range | Tier | Applies to | ✗ Anti-pattern | 🔍 machine_check |
162
+ |---|---|---|---|---|---|---|
163
+ | At scroll/drag boundaries apply rubberband resistance (drag translation = pow(rawOffset, ~0.7)) so the surface continues with diminishing offset instead of hard-stopping. (0.7 exponent is practitioner-derived; UIScrollView ships elastic by default.) | pow(offset, 0.7) | exponent 0-1 (~0.55-0.75 typical) | ⚪ OP | Over-scroll/over-drag past content or container bounds (scroll views, draggable sheets hitting a limit) | A dead hard-stop at the boundary (feels rigid/broken); linear drag past bounds (content flies off); bounces=false on content scrollers | At boundary verify drag translation = pow(rawOffset, k) with 0<k<1, not identity or 0; flag CSS overscroll that clamps instantly on touch surfaces |
164
+ | Model momentum/inertial scroll with exponential decay v(t)=v0·d^t using the platform deceleration constant (0.998 normal lists, 0.99 fast paging); project the fling's resting position X=x0+v0/(1000·\|ln d\|) to decide snap/dismiss, not raw displacement. | d = 0.998 (normal), 0.99 (fast); rest X = x0 + v0/(1000·\|ln d\|) · *exponential decay* | 0.99-0.998; projection factor ~499.5 (d=0.998) to ~99.5 (d=0.99) px per px/ms | 🟢 SPEC | Custom momentum/inertial scrolling, fling animations, swipe-to-dismiss commit, carousel snap selection | Linear/constant-friction deceleration (robotic, unlike native); deciding snap/dismiss from finger position at release only (a fast short flick should still commit) | Verify velocity update v(t)=v0·d^t with d∈[0.99,0.998] and resting projection; gesture-end handler computes endpoint = position + velocity·factor before choosing snap target |
165
+ | Dismiss a sheet/card when the drag passes ~30-50% of its height OR a fast flick's projected endpoint clears it; otherwise spring back. Two paths (distance OR velocity), not one. Trigger pull-to-refresh only past a fixed threshold (~80dp / 50-100px). | 30% distance (clamp >10%) or velocity flick; pull-to-refresh 80dp | dismiss 0.1-0.5 of element height; PTR 50-100px | 🟢 DS | Bottom sheets, dismissible cards, swipe-away modals; pull-to-refresh on lists/feeds | A pure 50% distance gate with no velocity path (a confident 20% flick springs back, feels broken); firing refresh on any over-pull (accidental); a threshold too large to reach on small screens | Verify dismiss has two paths (displacement/height ≥~0.3 OR projected endpoint past line); pull-to-refresh has an explicit distance gate (~80dp), flag <50dp or >100dp |
166
+ | Use scroll-snap proximity for long vertical content and mandatory + scroll-snap-stop:always for full-bleed carousels; set scroll-padding-top = fixed-header height so snapped/anchor content clears the header. | proximity (long content) / mandatory+snap-stop:always (carousels); scroll-padding-top = header height | proximity\|mandatory; scroll-padding 44-96px / 8-15vh | 🟢 SPEC | Scroll containers; vertical long-form vs horizontal one-item carousels; anchor/scrollIntoView under a sticky header | scroll-snap-type:y mandatory on overflowing content (content between snap points unreachable); leaving snap-stop:normal on a paged carousel (fast swipe overshoots); omitting scroll-padding (anchor content hides behind the fixed header) | Flag mandatory snap where a snap child overflows the container (use proximity); assert scroll-snap-stop:always on carousel items in a mandatory container; assert scroll-padding-top ≥ sticky header height |
167
+ | Put overscroll-behavior:contain on modals, drawers, chat panes, and nested scrollers to stop scroll-chaining to the page behind them; use overscroll-behavior-y:none only when you must also kill native pull-to-refresh and the boundary glow/rubber-band. | contain (overlays/nested) / none (kill PTR+glow) | auto \| contain \| none | 🟢 SPEC | Open dialog/overlay bodies, bottom sheets, chat lists, any inner overflow:auto over a scrollable page; app-shell PWAs with custom pull-to-refresh | Leaving auto so reaching the end of a modal's scroll bleeds into the page; reaching for `none` when you only need to stop chaining (also removes the boundary glow and native PTR) | Assert scrollable overlays/nested scrollers have overscroll-behavior contain or none (not auto); confirm `none` is intentional (paired with a custom refresh handler) |
168
+ | Register scroll/touchstart/touchmove/wheel listeners as {passive:true} unless you genuinely preventDefault; express directional scroll-locking with touch-action (pan-y for horizontal carousels; manipulation to drop the 300ms tap delay) instead of preventDefault; drive scroll-linked effects with IntersectionObserver / animation-timeline:scroll()/view(), not scroll-event JS. | passive:true listeners; touch-action:pan-y/manipulation; IntersectionObserver / scroll-driven timelines | — | 🟢 SPEC | Scroll/touch/wheel handlers; horizontal swipe widgets; sticky 'stuck' detection; reading-progress/parallax/reveal effects | Non-passive touch/wheel handlers on the scroll path (compositor waits for JS → input latency); preventDefault in touchmove to lock an axis (forces non-passive); per-frame scroll listeners mutating transform (main-thread jank); position:sticky has no native stuck event | Assert touch/wheel/scroll listeners pass {passive:true} or use touch-action; for horizontal swipe assert touch-action:pan-y with no preventDefault; prefer animation-timeline:scroll()/view() and IntersectionObserver+sentinel over scroll-handler-driven transforms |
169
+ | Enable CSS scroll-behavior:smooth only inside a prefers-reduced-motion:no-preference guard (UA controls duration/easing, not author-settable in CSS). | @media (prefers-reduced-motion: no-preference){ html{ scroll-behavior:smooth } } · *UA-defined* | UA-defined duration/easing | 🟢 SPEC | Anchor navigation and scrollIntoView smooth scrolling | Global html{scroll-behavior:smooth} with no reduced-motion gate (motion sickness); trying to set a custom duration in CSS (ignored) | Assert any scroll-behavior:smooth rule is nested under prefers-reduced-motion:no-preference; flag unconditional smooth scrolling |
170
+ | Pad fixed bottom bars with env(safe-area-inset-bottom) and offset fixed top headers by env(safe-area-inset-top) (with viewport-fit=cover) so controls clear the iOS home indicator / notch — never hardcode the px. | padding: env(safe-area-inset-bottom) / env(safe-area-inset-top) | bottom ~0-34px; top ~20-59px (device/orientation dependent) | 🟡 CONV | position:fixed bottom nav/CTA and top headers in viewport-fit=cover web apps on notched/home-indicator iPhones | Hardcoded padding-bottom:0/fixed-px (controls under the home indicator); top:0 fixed header overlapping the notch; hardcoding 34/44 (varies by device/zoom) | Require viewport-fit=cover; assert fixed bottom/top elements use env(safe-area-inset-*); flag fixed footers/headers with static px insets |
171
+
172
+ ## 11. Response Time & Perceived Performance
173
+
174
+ *The canonical perception thresholds (Nielsen/RAIL) anchor everything: paint a visible result within 100ms (process input <50ms, idle work in ≤50ms chunks) for 'instant'; respond within 400ms (Doherty) to hold flow; 1s is the flow-of-thought limit; past 10s show determinate progress + cancel. INP ≤200ms at p75 is the official Core Web Vital; CLS ≤0.1 at p75. Loading UI: no spinner under ~1s (gate behind a delay + minimum-visible to avoid flicker); skeletons for content-shaped regions, spinners for short discrete actions. Debounce search ~200-300ms, autosave/validation ~500ms; throttle scroll/resize/pointer streams.*
175
+
176
+ | Rule | Default | Range | Tier | Applies to | ✗ Anti-pattern | 🔍 machine_check |
177
+ |---|---|---|---|---|---|---|
178
+ | Paint a visible result within 100ms of a direct action (keep input-handler work <50ms, idle/background work in ≤50ms chunks) so it feels instantaneous — no loader needed below this. | 100ms feedback (50ms handler / 50ms idle chunk) | 0-100ms feedback; 0-50ms handler & idle chunk | 🟢 SPEC | Direct UI feedback to a discrete input (press state, toggle, tab switch, focus ring, local filter); JS event-handler time; non-urgent main-thread work | Showing a spinner for a sub-100ms action; deferring the :active/pressed style behind a JS round-trip; a single long sync task (>50ms) blocking input | INP/Event Timing per interaction ≤100ms; assert the active/aria-pressed style toggles synchronously in the handler (not after an await); flag main-thread tasks >50ms via PerformanceObserver longtask |
179
+ | Keep end-to-end response under 400ms (Doherty) to hold flow and 1s (Nielsen) before the sense of direct manipulation is lost; past 10s show a determinate percent-done indicator plus a cancel control. | 400ms flow ceiling; 1000ms flow-of-thought limit; 10s → percent-done + cancel | ≤400ms (flow); 100-1000ms (notices delay); ≥10s (determinate) | 🟢 SPEC | Interactive task loops (navigation, submit, search, command execution); long-running operations (uploads, exports, heavy compute) | Treating the old 2000ms standard as acceptable; an indeterminate spinner running 10s+ with no progress %, ETA, or cancel; the 90%-then-stall bar (feels deceptive) | Alert when p75 response >400ms; if an operation crosses 1000ms show a busy indicator; for >10s code paths assert a determinate progress bar bound to real progress plus a cancel control |
180
+ | Target Interaction to Next Paint ≤200ms at p75 (200-500ms needs-improvement, >500ms poor); render each animation frame in <16.6ms (60fps) — ~10ms script after browser overhead — animating only transform/opacity. | INP ≤200ms p75; 16.6ms frame budget (~10ms script) | INP ≤200/200-500/>500ms; frame <16.6ms (8.3ms for 120fps) | 🟢 SPEC | Web responsiveness Core Web Vital (full interaction latency p75); any continuous/main-thread animation | Optimizing only the average; long tasks pushing p75 INP past 200ms; layout/paint work in the animation loop (>16ms, dropped frames); leaving will-change permanently set | web-vitals onINP() p75 ≤200ms; break long tasks <50ms; per-frame <16.6ms; only animate compositor-friendly properties; will-change add-then-remove around the animation |
181
+ | Don't show a loading spinner for operations under ~1s: gate the loader behind a ~200ms-1s delay timer (cleared if the response resolves first) and, once shown, hold it a minimum (~500ms-1s) to avoid a flash. NOTE: concrete framework anchor is 200ms (Vue defineAsyncComponent); the 1s headline is inferred from Nielsen, not spec. | 200ms show-delay (framework default); min-visible ~500ms-1s | 200-1000ms delay | 🟢 DS | Deciding whether to render a spinner at all, by expected/elapsed operation time | A spinner that flashes for 150ms then vanishes; mounting the loader synchronously on request start regardless of speed | Loader render gated behind a setTimeout ~200-1000ms cleared on early resolve; once shown enforce a minimum visible duration (~500ms-1s) |
182
+ | Use a layout-matching skeleton for content-shaped surfaces (feeds, dashboards, lists) and a spinner for short discrete actions (save/auth/payment); only show either if the wait exceeds ~1s. NOTE: 'skeletons feel ~30% faster' is folklore — the layout-match principle is sound, the percentage is not. | skeleton for content regions / spinner for discrete actions; show only past ~1s | perceived-speed gain ~9-50% claimed but unverified; NN/g threshold = 1s (not 500ms) | ⚪ FOLK | Initial load of content-heavy predictable-layout views (skeleton); brief binary save/auth/payment actions (spinner) | A centered spinner over a content area with known layout; a skeleton whose shape doesn't match the real content; skeleton for a single button action; spinner for a known content grid | Content region + expected wait >~1s → skeleton with placeholder blocks mirroring final DOM (node count ≈ item count); discrete mutation → inline spinner/disabled button; flag content views with bare spinners |
183
+ | Animate a skeleton placeholder with a calm pulse (opacity 1→~0.5 over ~1.5-2s ease-in-out) or a left-to-right shimmer sweep (~1.4-2s linear, narrow highlight band via animated background-position), gated behind prefers-reduced-motion and animating only compositor-friendly properties. | pulse opacity 1→0.5, 2s ease-in-out; shimmer 1.5s linear background-position sweep · *ease-in-out (pulse) / linear (shimmer)* | pulse 1.5-2s (trough 0.4-0.5); shimmer 1.4-2.0s | 🟢 DS | Skeleton pulse and shimmer variants; one variant per surface kept in sync | Pulsing to opacity 0 (disappears); sub-1s sweep (strobe); >3s (frozen); animating left/right/background-position-x on layout (main-thread repaint); mixing pulse and wave on one screen; desynced per-element phases | Pulse 0%/100% opacity:1, 50% opacity 0.4-0.5, 1500-2000ms infinite; shimmer 1400-2000ms linear infinite, animate background-position/transform (not left/width); reduced-motion sets animation:none; resolves in 1-3s (WCAG 2.2.2 spirit) |
184
+ | Tween a changing number over ~0.4-1.0s (~700-750ms hero, shorter for small UI) with an ease-out/decelerate curve, rolling only the digit columns that changed; use tabular-nums and snap to the final value under reduced-motion. | 750ms, ease-out (M3 emphasized-decelerate cubic-bezier(0.05,0.7,0.1,1)); per-digit roll; tabular-nums · *ease-out / decelerate* | 400-1000ms; opacity fade ~0.5× spin; reduced-motion = 0ms snap | 🟡 CONV | Count-up/roll of a displayed numeric value (KPI, stat, price, balance, live counter) | Multi-second counts (Odometer/CountUp 2000ms default, too long); linear or ease-in easing; re-tweening the whole number so static high-order digits flicker; proportional digits (width jitter); running the spin under reduced-motion | Duration 400-1200ms; ease-out family (reject linear/ease-in); per-digit DOM (only changed columns translate); font-variant-numeric:tabular-nums; prefers-reduced-motion → snap or ≤150ms crossfade |
185
+ | Debounce live-search/typeahead ~200-300ms (trailing edge, sized to ~150ms inter-keystroke cadence); debounce autosave and async/remote validation ~500ms (with save-on-blur and a maxWait cap); show a pending state if a remote check exceeds ~1s. | search 200-300ms; autosave/validation 500ms; remote async <1s | search 150-300ms; autosave 500-3000ms; validation 300-500ms; maxWait 1000-2000ms | 🟡 CONV | Trailing-edge debounce of network/expensive work from text inputs (search, autocomplete, availability checks, draft persistence, inline validation) | Firing a request per keystroke (request storm); >300ms search debounce (laggy); reusing search-grade 200ms for autosave (hammers writes); plain debounce with no maxWait (continuous typist's draft never saves); debouncing the input echo itself (field feels broken) | Assert search debounce ~200-300ms trailing; autosave/validation ≥300-500ms with a blur/visibilitychange flush and a maxWait; the input's displayed value updates synchronously while only the derived query/fetch is debounced; count network calls per N chars typed |
186
+ | Throttle continuous streams — scroll/resize/pointer-move that PAINT to one frame (16ms via requestAnimationFrame), those that only SAMPLE to ~50-250ms; act on the trailing edge for resize. Use debounce for settled intent, throttle for sampled continuous activity. | rAF/16ms (painting) / 100ms (sampling); resize trailing-edge throttle | 16-250ms; scroll 16-20ms visual / 100-250ms checks | 🟡 CONV | scroll/resize/mousemove/pointermove/wheel/drag handlers doing layout reads or paints | DOM-mutating work directly in a scroll handler (jank); gating scroll work behind rAF (fires at the same rate as scroll); debouncing a scroll-progress indicator (frozen until you stop); throttling a smooth drag at 100ms (stutters) | Assert scroll/resize/pointer handlers are rate-limited; painting handlers use rAF (ticking flag), sampling handlers ~50-100ms throttle; resize is trailing; scroll/drag use throttle not debounce, search/autosave use debounce not throttle |
187
+ | When a real wait is unavoidable, bias the progress animation to feel faster: backward-moving, decelerating ribbing and front-loaded progress (fast start, slow end) cuts perceived duration ~11%; never let the value move backward or stall. | ~11% perceived-faster (backward decelerating ribbing) · *decelerating ribbing / front-loaded* | ~11% (peer-reviewed condition) | 🟢 SPEC | Determinate progress bars on unavoidable multi-second waits where actual speed-up isn't possible | Progress that visibly stalls or moves backward in value; linear bars that decelerate toward the end (feel slowest) | Ribbing/texture animates opposite to fill direction and decelerates; progress easing front-loads; verify the value never decreases |
188
+
189
+ ## 12. Layout Stability (CLS)
190
+
191
+ *Cumulative Layout Shift ≤0.1 at p75 (shifts ≥0.15 are consistently perceived as disruptive); score = impact × distance over a worst session window (gaps <1s, ≤5s). Reserve space for media (width/height attrs → aspect-ratio), ad slots (min-height), and async slots; never insert above existing content outside a 500ms user-input window. Stabilize the scrollbar gutter (scrollbar-gutter:stable, measured via innerWidth−clientWidth before locking overflow) and use font-display:optional or fallback metric overrides to kill font-swap shift. Animate transform, never layout properties. Accordions: animate grid-template-rows 0fr→1fr or interpolate-size, not height:auto.*
192
+
193
+ | Rule | Default | Range | Tier | Applies to | ✗ Anti-pattern | 🔍 machine_check |
194
+ |---|---|---|---|---|---|---|
195
+ | Keep Cumulative Layout Shift ≤0.1 at p75 of real-user loads (segmented mobile/desktop); shifts ≥0.15 are consistently perceived as disruptive and 0.25 is the start of 'poor'. Score = impact fraction × distance fraction over the worst session window (gaps <1s, ≤5s). | 0.1 | good ≤0.1 / needs-improvement >0.1-0.25 / poor >0.25; disruption onset 0.15 | 🟢 SPEC | Whole-page CLS Core Web Vital; field data at p75 | Judging CLS from one lab run or an average; treating 0.25 as the pass mark; any single session window approaching 0.25 | Sum LayoutShift entries (hadRecentInput=false) per session window (new window at gap >1000ms or age >5000ms); CLS = max window sum ≤0.1; or CrUX p75 ≤0.1 |
196
+ | Reserve space before content loads: width+height attributes on every <img>/<video> (→ aspect-ratio), CSS aspect-ratio for responsive media/embeds, min-height (sized to the largest creative) for ad/async slots, and never collapse a reserved slot to zero on empty fill. | width/height attrs; aspect-ratio; ad min-height = largest creative | intrinsic dims; ad slot ~50-280px min-height | 🟢 SPEC | All raster/video media, responsive embeds, display ad slots, async-injected widgets | Omitting dimensions (image pops in); height:0 collapse then jump; fixed ad height (crops larger creatives) or zero/auto (grows on fill); collapsing reserved space when no ad returns; fluid/late ad above the fold | querySelectorAll('img:not([width]),img:not([height]),video:not([width\|height])') empty for content media; media wrapper has aspect-ratio (or padding-bottom%); ad container min-height ≥ largest creative and keeps height on empty fill; no ad/widget intersects the top band above the LCP element |
197
+ | Never inject/expand content above existing content unless it's the direct ≤500ms result of a user interaction (scroll and pointer-move do NOT qualify); otherwise reserve space up front or append below the fold; animate movement with transform, never layout properties. | no above-content insertion outside the 500ms input window; transform-only animation | 500ms input grace window | 🟢 SPEC | Cookie/consent banners, notification strips, lazy-injected hero/promo content; all UI motion | A cookie banner pushing the page down on load; injecting promos above the fold after hydration with no reserved slot; animating left/top/width/height/margin (reflow + CLS) | Flag top-anchored injected elements (non-fixed/sticky) appearing post-load with hadRecentInput=false; prefer position:fixed/sticky or a pre-reserved min-height; flag animated top/left/width/height/margin on non-absolute elements (transform/opacity produce no LayoutShift) |
198
+ | Eliminate font-swap CLS: use font-display:optional (≤100ms block, no swap) for zero first-visit shift, or font-display:swap WITH a fallback @font-face carrying size-adjust/ascent-override/descent-override/line-gap-override tuned to occupy the same box. | font-display: optional, or swap + metric overrides | block ≤100ms; metric overrides per font pair (e.g. Inter→Arial size-adjust 107%) | 🟢 SPEC | @font-face declarations | font-display:swap with unmatched fallback metrics (reflow on swap); default auto/block (FOIT then swap); plain font-family fallback with no metric overrides | Grep @font-face for font-display:optional (no font-attributable LayoutShift on first load) or swap+size-adjust+ascent/descent-override; verify zero font-swap LayoutShift entries |
199
+ | Prevent scrollbar-induced shift: set scrollbar-gutter:stable on the scroll ROOT (:root/html, not body — not propagated like overflow) so locking body scroll for a modal doesn't shift content; use 'stable both-edges' for centered layouts; it's a no-op on overlay-scrollbar platforms. In JS fallbacks measure innerWidth−clientWidth BEFORE setting overflow:hidden, never a hardcoded 15px. | scrollbar-gutter: stable (root); JS gap = window.innerWidth - documentElement.clientWidth measured before lock | classic scrollbar ~15-17px; 0 on overlay | 🟢 SPEC | Scroll roots on classic-scrollbar platforms (Windows/Linux); modal/overlay open-close toggling body overflow | overflow:hidden on modal-open with no gutter reservation (content jumps right); scrollbar-gutter on <body> expecting viewport propagation; hardcoding padding-right:15px (varies by OS/theme/zoom; phantom gap on overlay platforms); measuring after overflow:hidden (returns 0) | Computed scrollbar-gutter:stable on documentElement; for centered layouts 'both-edges'; JS measures gap before mutating overflow; only compensate when innerWidth−clientWidth >0; toggle modal and assert clientWidth delta == 0 |
200
+ | Animate accordion/disclosure height via grid-template-rows 0fr→1fr (with the inner element overflow:hidden), or interpolate-size:allow-keywords / calc-size(auto) where supported — never transition straight to height:auto (a no-op); run it ~200-300ms (collapse ~0.8× expand) and gate behind reduced-motion. | grid-template-rows 0fr→1fr (inner overflow:hidden); 200-300ms; collapse 0.8× expand · *standard/decelerate-enter, accelerate-exit* | duration 200-300ms; collapse 0.75-0.85× expand | 🟢 DS | Disclosure/accordion panels revealing intrinsic-height content; CSS transition declaration | transition: height auto (no-op); max-height guesses (clip tall / lag short); overflow on the grid track instead of the inner child; vertical padding on the overflow child (leaks at 0fr); the grid 0fr trick for WIDTH (vertical-only); transition:all; no reduced-motion branch | Panel wrapper display:grid + transition grid-template-rows, collapsed 0fr / open 1fr, inner child overflow:hidden with no top/bottom padding; transition-property is the named size prop not 'all'; reduced-motion zeroes it; for height:auto use interpolate-size with @supports + length endpoint |
201
+
202
+ ## 13. Modals, Overlays & Z-Index
203
+
204
+ *Focus management is the spec backbone: on open move focus into the dialog (autofocus or container), trap Tab (wrap last↔first), close on Esc, restore focus to the trigger, and make the background inert (aria-modal/native showModal) — always with a keyboard-escapable exit (WCAG 2.1.2). Backdrop click dismisses non-destructive dialogs only; confirm before discarding unsaved input. Give role=dialog an accessible name and a 32% scrim. Z-index lives on a named, spaced ladder (Bootstrap 1000-1090) with tooltip/toast above modal; portal overlays to body and beware ancestor stacking contexts (transform/filter/opacity trap z-index); cap at the low thousands — 9999/INT_MAX signals a stacking-context bug.*
205
+
206
+ | Rule | Default | Range | Tier | Applies to | ✗ Anti-pattern | 🔍 machine_check |
207
+ |---|---|---|---|---|---|---|
208
+ | On modal open move keyboard focus into the dialog (autofocus the intended first action, or a tabindex=-1 container for long content); trap Tab so it wraps last↔first; close on Escape; on close restore focus to the invoking trigger. | focus first focusable / autofocus target; Tab wraps; Esc closes; restore to trigger | first focusable \| tabindex=-1 container \| least-destructive action | 🟢 SPEC | Modal dialog (role=dialog, aria-modal=true) open/close lifecycle and Tab/Shift+Tab/Esc | Leaving focus on the trigger/body so the first Tab lands outside; auto-focusing a destructive button; letting Tab reach the page behind the scrim; swallowing Esc; dropping focus to <body> on close | On open assert dialogEl.contains(activeElement); Tab from last focusable → first and Shift+Tab from first → last; Esc closes (dialog.open===false); before open capture trigger, after close assert activeElement===trigger; native <dialog>.showModal() handles trap/inert automatically |
209
+ | While the modal is open make the rest of the page inert (aria-modal=true + inert / native top-layer) so nothing behind the scrim is focusable, clickable, or read by AT — but always keep a keyboard-escapable exit (Esc / focusable close); never a dead-end trap. | aria-modal=true + inert background; Esc/close always available | WCAG 2.1.2 No Keyboard Trap (Level A) — mandatory exit | 🟢 SPEC | All page content outside the dialog while a modal is displayed; keyboard-only users | Background links/buttons still tabbable; aria-hidden on the dialog's own ancestor (hides the dialog); a custom focus loop that captures Tab but blocks Esc with no focusable close | Dialog has aria-modal='true' AND background nodes are non-interactive (inert / pointer-events:none + tabindex removed); no element outside the dialog receives Tab; Esc closes OR a tabbable close control exists inside |
210
+ | Give the overlay role=dialog (or native <dialog>) with an accessible name via aria-labelledby (visible title) or aria-label; keep the focused element at least partially visible (not hidden behind a sticky header/scrim); dim the background with a ~32% scrim. | role=dialog + aria-labelledby; focus partially visible; scrim #000 @32% | aria-labelledby preferred; WCAG 2.4.11 partial / 2.4.12 full focus visibility; scrim 30-60% | 🟢 SPEC | The dialog container, its focus visibility, and its backdrop | An unnamed <div> overlay (announced as generic group); aria-labelledby pointing at a missing id; a pinned footer/header overlapping the focused field; no scrim (modal floats ambiguously) | Container has role='dialog'/<dialog> and a non-empty accessible name; for each Tab stop the focused element's rect isn't fully covered by sticky/fixed elements; scrim element background rgba(0,0,0,~0.32) blocking pointer events |
211
+ | Allow backdrop/scrim click to dismiss non-destructive dialogs (light dismiss) but disable it for forms with unsaved input or destructive flows; if closing would lose user-entered content, intercept and confirm/discard instead of silently destroying it. | light dismiss for transient/info dialogs; off for data-entry/destructive; confirm-on-dirty close | closedby='any' (transient) vs 'closerequest' (forms/destructive) | 🟢 SPEC | Pointer-down outside the dialog; Esc/close of a dialog whose form has unsaved changes | A multi-field form or destructive confirm dismissable by an outside tap (one stray click wipes input); Esc instantly discarding a half-filled form; nagging confirm on trivially-reversible actions | For native <dialog> forms use closedby='closerequest' (or showModal which excludes outside-click); for Radix preventDefault onPointerDownOutside when form is dirty; when form.dirty the close handler preventDefaults and surfaces a confirm step |
212
+ | Assign every overlay to a named, role-based z-index token on a small ordered ladder (dropdown<sticky<fixed<offcanvas<modal<popover<tooltip<toast), spaced ~10-20 apart with backdrops one step below their surface, tooltip/toast ABOVE modal; cap in the low thousands. | Bootstrap ladder 1000-1090 (dropdown 1000, modal 1055, popover 1070, tooltip 1080, toast 1090); skipLink topmost | 1000-1090; component-internal 1-9; step 10-20 | 🟢 DS | z-index on all positioned overlay surfaces; component-internal layering uses a separate single-digit band | Hardcoding 999/9999/2147483647 per component; tooltip/toast ≤ modal (clipped behind it); sequential 1,2,3 tokens (renumber on every insertion); overriding one component's z-index in isolation; a skip link below a sticky header | Flag z-index not from a shared token set, any ≥10000, and {999,9999,99999,2147483647}; assert token(tooltip/toast) > token(modal), token(skipLink) ≥ max, backdrop = surface−(5..10), adjacent deltas ≥5; component-internal z-index ≤9 with isolation:isolate |
213
+ | Portal modals/popovers/dropdowns/tooltips/toasts to document.body (or one overlay root) to escape overflow:clip and ancestor stacking contexts; know that transform/filter/opacity<1/will-change/isolation/contain/position fixed\|sticky on an ancestor create a stacking context that TRAPS descendant z-index — portal out (or isolation:isolate to fence internals), don't inflate the number. | portal to body; once portaled, stack by DOM/mount order; isolation:isolate to fence component internals | stacking-context triggers: opacity<1, transform/filter/backdrop-filter/perspective/clip-path/mask≠none, mix-blend-mode≠normal, isolation:isolate, will-change, contain, container-type, position fixed/sticky | 🟢 SPEC | Any floating overlay that could be clipped or re-stacked by an ancestor; self-contained components with internal z-index | Rendering a dropdown/tooltip inline inside overflow:hidden or a transformed/filtered ancestor (clipped/trapped); adding opacity/transform/filter to a wrapper then inflating the child's z-index to escape (impossible); reaching for 9999 instead of a portal | Assert overlay nodes are near children of body/overlay root, not nested in overflow:hidden\|auto or stacking-context ancestors; walk the ancestor chain and flag opacity<1/transform/filter/etc.; components with internal z-index declare isolation:isolate; flag z-index ≥9999 / INT_MAX as a portal-needed smell |
214
+
215
+ ## 14. Performance & GPU Discipline
216
+
217
+ *Animate only transform and opacity (composite-only); never `transition: all` — list properties explicitly. Layout/paint properties (width/height/top/left/margin/padding) trigger reflow and jank. Use will-change only for compositor properties and only while animating (remove at rest). Treat blur() as a Gaussian std-dev (perceived spread ~2-3× the value). These rules underpin both motion feel and CLS.*
218
+
219
+ | Rule | Default | Range | Tier | Applies to | ✗ Anti-pattern | 🔍 machine_check |
220
+ |---|---|---|---|---|---|---|
221
+ | Animate only transform and opacity (composite-only, GPU); never use `transition: all` — explicitly list the properties that change. Layout properties (width/height/top/left/margin/padding) trigger layout+paint+composite and jank. | transition: <named props> (transform, opacity); never 'all' | allowed: transform, opacity, filter; forbidden in motion-critical UI: width, height, top, left, right, bottom, margin, padding | 🟢 SPEC | All performance-sensitive animations/transitions, especially repeated/scroll-driven/per-frame; every transition declaration | transition: all (silently animates layout props → jank, unintended transitions); animating margin/padding/width/height/top/left (layout thrash) | Grep for 'transition: all' / 'transition-property: all' → flag; flag any animated property in {width,height,top,left,right,bottom,margin,padding}; assert animated props ⊆ {transform, opacity, filter, color, background-color} |
222
+ | Use will-change only for compositor-promotable properties (transform/opacity/filter/clip-path) and only while actively animating (add then remove); never will-change:all, never on layout properties, never blanket across a grid. | will-change: transform, opacity (transient, removed at rest) | allowed: transform, opacity, filter, clip-path; remove when idle | 🟡 CONV | Elements animating transform/opacity/filter that show first-frame stutter (Safari especially); hot/frequently-updating components | will-change:all; will-change on background/padding/color (not compositable); permanent will-change on idle elements (layer explosion, wasted GPU memory); blanket on every .card in a grid | Reject will-change:all and values containing background/border/color/padding/top/left/width/height; allow only transform/opacity/filter/clip-path; flag if present on elements with no matching animation or applied globally rather than transiently |
223
+ | Treat the blur() px value as the Gaussian standard deviation — a given N spreads visibly wider than N pixels (budget perceived spread ~2-3×); keep backdrop-filter blur 8-24px (~12-16px default), low (≤10px) on mobile fixed elements to avoid scroll jank. | blur(Npx) = std-dev N; backdrop blur 16px default, ≤10px mobile fixed | blur 8-24px; mobile fixed ≤10px | 🟢 SPEC | Interpreting/porting blur() radii between platforms; frosted-glass backdrop-filter on chrome | Assuming blur(20px) means a 20px-radius kernel (copying a Figma 'blur radius' 1:1 looks far blurrier); large backdrop blur on position:fixed over scrolling content (iOS Safari per-frame repaint jank) | Document CSS blur(N) == feGaussianBlur stdDeviation N when porting tokens; warn backdrop blur >10px on mobile fixed/sticky elements; flag stacked blurred fixed layers |
224
+
225
+ ## 15. Material & Glass (backdrop-filter)
226
+
227
+ *Frosted glass = blur 8-24px + saturate(120-200%) + a translucent fill (10-25% for decorative cards, 50-80% for text-bearing chrome) + a 1px translucent hairline border, always behind an opaque @supports fallback and with the -webkit- prefix for Safari. Contrast must be computed against the worst-case show-through background (≥4.5:1), fill opacity raised over busy backgrounds, foreground in vibrant/dynamic colors, and a prefers-reduced-transparency branch swapping to near-opaque high-contrast.*
228
+
229
+ | Rule | Default | Range | Tier | Applies to | ✗ Anti-pattern | 🔍 machine_check |
230
+ |---|---|---|---|---|---|---|
231
+ | Build frosted glass from blur 8-24px + saturate(120-200%) + a translucent fill (10-25% decorative cards, 50-80% text-bearing chrome) + a 1px translucent light hairline; ship behind an opaque @supports fallback (≥0.9 alpha base) and emit -webkit-backdrop-filter for Safari (never a CSS variable as its value). | blur(16px) saturate(180%); fill rgb(255 255 255 / 0.6); border 1px rgb(255 255 255 / 0.2); fallback alpha 0.95 | blur 8-24px; saturate 1.2-2.0; fill alpha 0.10-0.80; border alpha 0.1-0.3 | 🟡 CONV | Translucent chrome surfaces (sticky headers, nav bars, modal/sheet overlays, glass cards) | Blur <6px (flat tinted panel); >30px (backdrop erased); blur alone with no saturate (gray muddiness); fill alpha ~0 (text contrast collapses) or 1.0 (no translucency); borderless edge; only unprefixed backdrop-filter (silent fail in Safari); CSS var on -webkit-backdrop-filter (ignored) | Parse backdrop-filter: blur 8-24px + saturate 1.2-2.0; background-color alpha strictly 0<a<1 (≥0.5 for text chrome); a sibling -webkit-backdrop-filter (no var()); base rule ≥0.9 alpha with the translucent fill inside @supports |
232
+ | Compute text contrast against the WORST-CASE show-through background (≥4.5:1 text / ≥3:1 large/UI), raise fill opacity (frostier) as the backdrop gets busier, render foreground in vibrant/dynamic system colors (not fixed hex), and add a prefers-reduced-transparency / increased-contrast branch swapping glass to near-opaque high-contrast. | ≥4.5:1 over worst-case backdrop; +0.15-0.30 fill alpha over busy content; vibrant foreground; reduced-transparency → alpha ≥0.9 | text 4.5:1 / large 3:1; busy-bg fill 0.6-0.85 | 🟢 SPEC | Any text/glyph on translucent material over variable content; glass scrolling over photos or dense UI; accessibility settings | Validating contrast only against the fill while ignoring show-through (text drops below 4.5:1 over a busy photo); one low fixed alpha everywhere; hardcoded #000/#FFF on adaptive material; glass that ignores Reduce Transparency | Flatten the fill over the darkest AND lightest backdrop pixels and require ≥4.5:1 (text)/≥3:1 (large/UI) in both; glass over <img>/video/gradient has higher alpha than over solid; foreground resolves from dynamic tokens not literal hex; @media (prefers-reduced-transparency: reduce) raises fill alpha ≥0.9 with a contrasting border |
233
+
234
+ ## 16. Accessibility & Motion-Safety
235
+
236
+ *prefers-reduced-motion is the non-negotiable backbone: under `reduce`, cut MOTION (transform/translate/scale/rotate/scroll/parallax — and blur, now in-scope per WCAG 2.3.3 errata) while KEEPING opacity/color, substituting a cross-fade rather than deleting transitions; collapse durations to ~0.01ms (not 0ms) globally. WCAG bright lines: ≤3 flashes/1s (general luminance threshold 10%, not 25%); auto-moving content >5s needs pause/stop/hide; interaction-triggered motion must be disablable (2.3.3 AAA). Focus rules: 3:1 + 2px perimeter (SC 2.4.13), never outline:none without a replacement (2.4.7), :focus-visible not :focus, focus order = DOM order (no positive tabindex), hide focusables by positioning not display:none. Gate hover behind @media (hover:hover); set input font-size ≥16px to block iOS zoom.*
237
+
238
+ | Rule | Default | Range | Tier | Applies to | ✗ Anti-pattern | 🔍 machine_check |
239
+ |---|---|---|---|---|---|---|
240
+ | Gate all non-essential movement behind prefers-reduced-motion: under `reduce` cut MOTION (transform/translate/scale/rotate, position, scroll, parallax, blur) and KEEP opacity/color, substituting a cross-fade rather than deleting the transition; collapse global animation/transition durations to ~0.01ms (not 0ms, which skips transitionend) and force scroll-behavior:auto. | @media (prefers-reduced-motion: reduce) → cross-fade/instant; durations 0.01ms; scroll-behavior:auto | 0.001-0.01ms; cut transform/scroll/parallax/blur, keep opacity/color | 🟢 SPEC | All non-essential motion (large movement, parallax, zoom, spin, auto-play, smooth-scroll); the global reset | Shipping movement/parallax/spin with no reduced-motion fallback (vestibular triggers); disabling EVERY animation (looks broken — keep opacity/state changes); animation-duration:0ms (skips animationend/transitionend); keeping transform-based motion or smooth-scroll under reduce; (note: blur is now IN motion scope per 2.3.3 errata — no longer safe to retain) | Every transform/parallax/scroll-smooth animation has a prefers-reduced-motion override (CSS @media or useReducedMotion); reduce branch neutralizes transform/translate/scale/rotate/scroll/blur while allowing opacity/color; global reset sets duration ~0.01ms + scroll-behavior:auto; flag transform animation surviving the reduce branch |
241
+ | Replace slide/zoom/parallax with a cross-dissolve/fade when Reduce Motion is on (iOS App Store criteria) — keep conveying meaning without large positional motion; vestibular risk scales with how much of the VIEWPORT the motion sweeps, not physical screen size. | cross-dissolve / fade substitute; large-area sweep = trigger, small localized = safe | trigger threshold rises sharply approaching full-viewport (>~50vw/50vh heuristic) | 🟢 SPEC | Hierarchical/contextual transitions, parallax, depth/blur, spin, page/route transitions, full-screen wipes when accessibilityReduceMotion is true | Keeping full slide/zoom/parallax under Reduce Motion; removing the transition entirely (meaning lost); full-screen wipe / large-distance slide / mismatched-rate parallax (covers >10° of visual field, triggers nausea) | Branch on isReduceMotionEnabled / accessibilityReduceMotion and swap to cross-dissolve; flag keyframes where translate distance ≥~50vw/50vh or near-full-viewport parallax not gated behind prefers-reduced-motion:no-preference |
242
+ | Nothing may flash more than 3 times in any 1-second window; auto-starting moving/blinking/scrolling content running >5s alongside other content needs a pause/stop/hide control; interaction-triggered non-essential motion must be disablable. | ≤3 flashes/1s; >5s auto-motion → pause/stop/hide; interaction motion disablable | general flash threshold = ≥10% relative-luminance change over >10° visual field (NOT 25%); 5s grace (none for auto-updating) | 🟢 SPEC | Flashing/strobing UI, auto-advancing carousels/marquees/banners, live tickers, scroll/hover/click-triggered parallax (WCAG 2.3.1 Level A, 2.2.2 Level A, 2.3.3 AAA) | A large element strobing >3×/sec; a >10% luminance flash over >10° of field; a carousel auto-rotating with no pause; assuming auto-updating feeds get the 5s grace (they don't); ungated parallax | Flag animations implying >3 cycles/sec on large elements (duration <~0.333s with high-contrast swings on near-full-viewport); auto-playing carousels/marquees need a pause/stop control OR run ≤5s; interaction-driven transform animations wrapped in prefers-reduced-motion:no-preference or a matchMedia guard |
243
+ | Never set outline:none on a focusable element without a visible replacement; scope focus rings to :focus-visible (not :focus); render rings with box-shadow/radius-aware outline (bare outline ignored border-radius historically); keep focus order = DOM source order (tabindex only in {-1,0}); hide focusable offscreen elements by positioning, not display:none. | outline never removed without replacement; :focus-visible; box-shadow ring; tabindex ∈ {-1,0}; clip/position-absolute hiding | WCAG 2.4.7 (A), 2.4.3 (A); tabindex ≤0 | 🟢 SPEC | Focus styling/order on all interactive controls; visually-hidden but keyboard-reachable controls (skip links) | *:focus{outline:none} with no replacement; :focus ring flashing on mouse click (leading to its removal); outline on a rounded element clipping corners; tabindex≥1 (yanks order, breaks on new controls); display:none/visibility:hidden on a skip link (removes it from tab order) | Flag outline:none/0 on :focus/:focus-visible without a compensating ring; assert rings use :focus-visible and box-shadow on radius>0; grep tabindex ≥1 → flag; skip links hidden via clip/position-absolute with a :focus reveal, not display:none |
244
+ | Gate hover styles behind @media (hover: hover) and (pointer: fine) so touch taps don't get stuck in a hover state, and set form input font-size ≥16px so iOS Safari doesn't auto-zoom on focus (or pin maximum-scale=1); never autofocus inputs on touch. | @media (hover: hover) and (pointer: fine) for hover; input font-size ≥16px | hover gated on fine pointer; input ≥16px | 🟢 SPEC | All :hover visual changes (color, elevation, reveal-on-hover); <input>/<textarea>/<select> font-size on mobile web | Bare :hover rules (stuck hover after tap on touch; reveal-on-hover controls unreachable); 14px input text (iOS zooms/pans on focus, breaks layout); autofocus on touch (keyboard covers screen) | Flag :hover rules with visual effect not wrapped in @media (hover: hover); flag controls only revealed on :hover with no touch equivalent; query computed input font-size <16px → flag; flag autofocus on inputs when pointer:coarse |
245
+ | When an input error is automatically detected, identify the field and describe the error in TEXT (not color/icon alone, aria-invalid + aria-describedby); never silently block submit — surface the first invalid field, move focus to it or to a top error summary (tabindex=-1, focused, 'Error:' page title), and don't shift layout when the message appears. | text error + field identified; focus first invalid / error summary on failed submit; 0 layout shift | WCAG 3.3.1 (Level A) normative; reserved-space error slot | 🟢 SPEC | Automatically-detected form input errors; failed-submit recovery; inline error rendering | Error signaled by red border/icon only with no text; a generic 'form has errors'; disabling submit with no explanation; leaving focus on the button; error text popping in and pushing the submit button down (moves the tap target / harms magnifier users) | Each invalid field conveys error in text, has aria-invalid='true', aria-describedby → visible error node; on failed submit activeElement is the first invalid field OR a tabindex=-1 error summary that received focus with links and an 'Error:' title prefix; toggling an error causes ~0 submit-button movement |
246
+
247
+ ## 17. Form Validation Timing
248
+
249
+ *Inline validation follows 'reward early, punish late' (Konjević/Baymard): validate a clean field only on blur (and only when non-empty), but once an error is shown, switch that field to live keystroke revalidation so the error clears the instant the input becomes valid; fixed-length fields may validate at length; async checks are debounced ~500ms with a pending state past ~1s. A documented counter-position (GOV.UK) validates on submit only for accessibility-first, slow-typist/AT audiences — choose per audience.*
250
+
251
+ | Rule | Default | Range | Tier | Applies to | ✗ Anti-pattern | 🔍 machine_check |
252
+ |---|---|---|---|---|---|---|
253
+ | Validate a clean field only on blur (and only when non-empty); the moment a field shows an error, switch it to live keystroke revalidation so the error clears as soon as the input becomes valid — 'reward early, punish late'. | blur for first pass; on-change (keystroke) after an error; clear error live (0ms) | first pass on blur; error-clearing live every keystroke | 🟡 CONV | Per-field client-side inline validation of a non-empty text field in a multi-field web form | Validating a pristine field on every keystroke before the user finishes (accusatory); keeping a shown error stale until the next blur while the user is actively fixing it; throwing a required-empty error on an untouched field | A never-errored field validates only on blur/change, never on input; after an error renders, the same field revalidates on input and removes the error on the keystroke validity becomes true; untouched empty field shows no inline error (required-empty errors at submit) |
254
+ | For fixed-length constrained fields (ZIP, phone, card number) you may validate as soon as the input reaches the expected length; confirm correct entries with a positive inline marker (post-blur, not mid-typing). For high-assurance/accessibility-first forms a documented counter-position validates on SUBMIT only — choose per audience. | validate at correct length (fixed-length); positive marker post-blur; submit-only for AT-first audiences | length-gated; or no on-blur/on-input (GOV.UK counter-position) | 🟡 CONV | Fixed-length fields; success states; government/transactional one-thing-per-page forms (deliberate disagreement with on-blur guidance) | A 'too short' error while still typing toward required length; a green checkmark on every keystroke before complete; assuming inline/keystroke validation is universally correct (fires mid-word for slow typists/AT users) | Fixed-length field validates when value.length === expectedLength (not shorter); success marker only after a valid value AND blur/complete pattern; if following the GOV.UK pattern, assert no blur/input validation handlers and validation runs only on submit |
@@ -578,7 +578,7 @@ master에게 전달되는 prompt 첫 단락은 **반드시** 다음을 포함:
578
578
  1. `.claude/data/references/<id>/DESIGN.md` (installer가 복사 — npx 설치 기본 경로)
579
579
  2. `node_modules/oh-my-design-cli/web/references/<id>/DESIGN.md` (로컬 npm 설치 직접 경로)
580
580
  3. `web/references/<id>/DESIGN.md` (개발 레포)
581
- 4. `https://oh-my-design.kr/design-systems/<id>.md` 를 fetch (WebFetch 또는 `curl -fsSL`) — 200이면 본문이 곧 reference DESIGN.md. 가져온 내용을 `.claude/data/references/<id>/DESIGN.md`로 캐시해 다음부터는 로컬 캐시(경로 1)로 잡히게 한다.
581
+ 4. `https://oh-my-design.kr/<id>/design.md` 를 fetch (WebFetch 또는 `curl -fsSL`) — 200이면 본문이 곧 reference DESIGN.md. 가져온 내용을 `.claude/data/references/<id>/DESIGN.md`로 캐시해 다음부터는 로컬 캐시(경로 1)로 잡히게 한다.
582
582
  2. 사용자가 같은 brand로 **이미 한 번 실험**했으면 (`.omd/runs/INDEX.md`에 기록) → 2순위 사용해서 variation 제공
583
583
  3. 사용자가 명시 ("center 정렬로", "carousel로") → 그대로 따름
584
584
  4. 선택된 archetype을 `experiment-meta.json`의 `hero_archetype` 필드에 명시 (gallery 표시용)
@@ -129,7 +129,7 @@ Phase 4.2~6은 건너뛴다.
129
129
  1. `.claude/data/references/<id>/DESIGN.md` (installer가 복사 — npx 설치 기본 경로)
130
130
  2. `node_modules/oh-my-design-cli/web/references/<id>/DESIGN.md` (로컬 npm 설치 직접 경로)
131
131
  3. `web/references/<id>/DESIGN.md` (개발 레포)
132
- 4. `https://oh-my-design.kr/design-systems/<id>.md` 를 fetch (WebFetch 또는 `curl -fsSL`) — 1~3 로컬 경로가 전부 없을 때 (npx 설치가 기본 경로라 흔한 상황). 200이면 응답 본문이 곧 reference DESIGN.md다. 가져온 내용을 `.claude/data/references/<id>/DESIGN.md`로 저장해 다음 실행부터는 로컬 캐시(경로 1)로 잡히게 한다.
132
+ 4. `https://oh-my-design.kr/<id>/design.md` 를 fetch (WebFetch 또는 `curl -fsSL`) — 1~3 로컬 경로가 전부 없을 때 (npx 설치가 기본 경로라 흔한 상황). 200이면 응답 본문이 곧 reference DESIGN.md다. 가져온 내용을 `.claude/data/references/<id>/DESIGN.md`로 저장해 다음 실행부터는 로컬 캐시(경로 1)로 잡히게 한다.
133
133
 
134
134
  4까지 전부 실패하면 **절대 DESIGN.md를 임의로 지어내지 말 것.** 사용자에게
135
135
  "레퍼런스 `<id>` 원문을 찾지 못했어요 (오프라인이거나 카탈로그 미배포).
@@ -109,7 +109,7 @@ id가 카탈로그에 없으면 종료 + "X는 reference 카탈로그에 없어
109
109
  1. `.claude/data/references/<id>/DESIGN.md` (installer가 복사 — npx 설치 기본 경로; 디렉토리에는 **DESIGN.md만** 보장)
110
110
  2. `node_modules/oh-my-design-cli/web/references/<id>/DESIGN.md` (로컬 npm 설치 직접 경로 — 디렉토리에 _promo.json/_research.md 포함)
111
111
  3. `web/references/<id>/DESIGN.md` (개발 레포)
112
- 4. `https://oh-my-design.kr/design-systems/<id>.md` 를 fetch (WebFetch 또는 `curl -fsSL`) — 1~3 로컬 경로가 전부 없을 때. 200이면 본문이 곧 reference DESIGN.md. 가져온 내용을 `.claude/data/references/<id>/DESIGN.md`로 캐시해 다음부터는 로컬 캐시(경로 1)로 잡히게 한다.
112
+ 4. `https://oh-my-design.kr/<id>/design.md` 를 fetch (WebFetch 또는 `curl -fsSL`) — 1~3 로컬 경로가 전부 없을 때. 200이면 본문이 곧 reference DESIGN.md. 가져온 내용을 `.claude/data/references/<id>/DESIGN.md`로 캐시해 다음부터는 로컬 캐시(경로 1)로 잡히게 한다.
113
113
 
114
114
  `<refdir>` = resolve된 DESIGN.md가 있는 디렉토리 (tier 4로 fetch한 경우 캐시 후 `.claude/data/references/<id>/`). `_promo.json`/`_research.md`는 (1)/(4)에 없을 수 있으니, 없으면 (2)/(3)로 폴백하고 그래도 없으면 fingerprints 기반 추론으로 진행.
115
115