@godxjp/ui 21.0.0 → 23.0.0

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 (154) hide show
  1. package/README.md +4 -4
  2. package/dist/app/app-provider.js +2 -1
  3. package/dist/components/data-display/badge.d.ts +28 -1
  4. package/dist/components/data-display/badge.js +2 -0
  5. package/dist/components/data-display/data-table.d.ts +18 -2
  6. package/dist/components/data-display/data-table.js +4 -0
  7. package/dist/components/data-display/index.d.ts +1 -1
  8. package/dist/components/data-display/popover.d.ts +19 -1
  9. package/dist/components/data-display/popover.js +9 -1
  10. package/dist/components/data-display/progress.d.ts +37 -0
  11. package/dist/components/data-display/progress.js +57 -5
  12. package/dist/components/data-display/scroll-area.d.ts +24 -4
  13. package/dist/components/data-display/scroll-area.js +34 -72
  14. package/dist/components/data-entry/cascader.js +8 -6
  15. package/dist/components/data-entry/checkbox.js +2 -0
  16. package/dist/components/data-entry/choice-hit-target.d.ts +24 -0
  17. package/dist/components/data-entry/choice-hit-target.js +12 -0
  18. package/dist/components/data-entry/control-surface.d.ts +8 -0
  19. package/dist/components/data-entry/control-surface.js +10 -1
  20. package/dist/components/data-entry/date-picker.d.ts +10 -3
  21. package/dist/components/data-entry/date-picker.js +503 -238
  22. package/dist/components/data-entry/form-field.d.ts +1 -1
  23. package/dist/components/data-entry/form-field.js +7 -3
  24. package/dist/components/data-entry/form.js +10 -1
  25. package/dist/components/data-entry/index.d.ts +0 -6
  26. package/dist/components/data-entry/index.js +0 -6
  27. package/dist/components/data-entry/label.d.ts +14 -1
  28. package/dist/components/data-entry/label.js +6 -1
  29. package/dist/components/data-entry/radio.d.ts +27 -6
  30. package/dist/components/data-entry/radio.js +89 -43
  31. package/dist/components/data-entry/search-select.js +226 -89
  32. package/dist/components/data-entry/select.d.ts +76 -14
  33. package/dist/components/data-entry/select.js +497 -163
  34. package/dist/components/data-entry/slider.d.ts +3 -11
  35. package/dist/components/data-entry/slider.js +582 -93
  36. package/dist/components/data-entry/switch.d.ts +6 -3
  37. package/dist/components/data-entry/switch.js +36 -11
  38. package/dist/components/layout/mobile-shell.d.ts +1 -1
  39. package/dist/components/layout/mobile-shell.js +2 -0
  40. package/dist/components/navigation/dropdown-menu.d.ts +11 -3
  41. package/dist/components/navigation/dropdown-menu.js +190 -15
  42. package/dist/components/navigation/index.d.ts +0 -3
  43. package/dist/components/navigation/index.js +0 -78
  44. package/dist/components/navigation/steps.d.ts +1 -1
  45. package/dist/components/navigation/steps.js +3 -1
  46. package/dist/components/navigation/tabs-scroll.d.ts +27 -0
  47. package/dist/components/navigation/tabs-scroll.js +52 -1
  48. package/dist/components/navigation/tabs.d.ts +2 -2
  49. package/dist/components/navigation/tabs.js +88 -23
  50. package/dist/components/ui/index.d.ts +0 -4
  51. package/dist/components/ui/index.js +0 -4
  52. package/dist/components/ui/input-otp.d.ts +1 -1
  53. package/dist/components/ui/segmented.d.ts +15 -1
  54. package/dist/components/ui/segmented.js +15 -6
  55. package/dist/components/ui/tag-input.js +3 -5
  56. package/dist/i18n/messages/en.json +3 -2
  57. package/dist/i18n/messages/ja.json +3 -2
  58. package/dist/i18n/messages/vi.json +3 -2
  59. package/dist/lib/datetime/picker-format.d.ts +18 -0
  60. package/dist/lib/datetime/picker-format.js +35 -1
  61. package/dist/lib/field-a11y.d.ts +12 -0
  62. package/dist/lib/field-a11y.js +14 -1
  63. package/dist/lib/select-options.d.ts +21 -0
  64. package/dist/lib/select-options.js +46 -0
  65. package/dist/props/components/data-display.prop.d.ts +16 -6
  66. package/dist/props/components/data-entry.prop.d.ts +503 -129
  67. package/dist/props/components/index.d.ts +2 -2
  68. package/dist/props/components/layout.prop.d.ts +12 -1
  69. package/dist/props/components/navigation.prop.d.ts +93 -1
  70. package/dist/props/registry.d.ts +79 -16
  71. package/dist/props/registry.js +85 -15
  72. package/dist/props/vocabulary/data.prop.d.ts +12 -0
  73. package/dist/props/vocabulary/index.d.ts +2 -2
  74. package/dist/props/vocabulary/layout.prop.d.ts +11 -0
  75. package/dist/styles/badge-layout.css +4 -0
  76. package/dist/styles/card-layout.css +6 -6
  77. package/dist/styles/control.css +220 -29
  78. package/dist/styles/data-display-layout.css +94 -23
  79. package/dist/styles/focus-ring.css +6 -4
  80. package/dist/styles/fonts.css +88 -4
  81. package/dist/styles/navigation-layout.css +36 -236
  82. package/dist/styles/shell-layout.css +149 -139
  83. package/dist/styles/table-layout.css +41 -3
  84. package/dist/tokens/components/control.css +4 -0
  85. package/dist/tokens/components/data-display.css +9 -5
  86. package/dist/tokens/components/navigation.css +9 -5
  87. package/dist/tokens/components/scroll-area.css +7 -0
  88. package/dist/tokens/components/segmented.css +0 -2
  89. package/dist/tokens/components/shell.css +2 -0
  90. package/dist/tokens/components/table.css +3 -0
  91. package/dist/tokens/foundation.css +7 -0
  92. package/docs/COMPONENTS.md +14 -14
  93. package/docs/CONSUMER-RULES.md +24 -1
  94. package/docs/CUSTOMER-THEMING.md +7 -1
  95. package/docs/FRAME-COVERAGE-REPORT.md +5 -11
  96. package/docs/TOKENS.md +65 -0
  97. package/docs/data-display/badge.tsx +37 -0
  98. package/docs/data-display/data-table/index.tsx +45 -1
  99. package/docs/data-display/popover.tsx +43 -5
  100. package/docs/data-display/progress.tsx +28 -0
  101. package/docs/data-display/scroll-area.tsx +51 -45
  102. package/docs/data-entry/calendar.tsx +2 -2
  103. package/docs/data-entry/date-picker.tsx +144 -5
  104. package/docs/data-entry/form/examples/invoice-form.tsx +2 -2
  105. package/docs/data-entry/form-field/examples/a11y-contract.tsx +3 -5
  106. package/docs/data-entry/form.tsx +4 -4
  107. package/docs/data-entry/input-otp.tsx +5 -2
  108. package/docs/data-entry/number-input.tsx +31 -0
  109. package/docs/data-entry/segmented.tsx +143 -2
  110. package/docs/data-entry/select-matrix.tsx +1 -2
  111. package/docs/data-entry/select.tsx +108 -3
  112. package/docs/data-entry/slider.tsx +125 -47
  113. package/docs/feedback/dialog.tsx +1 -2
  114. package/docs/feedback/sheet.tsx +1 -2
  115. package/docs/layout/app-shell-states.tsx +143 -0
  116. package/docs/layout/mobile-shell.tsx +5 -0
  117. package/docs/navigation/dropdown-menu.tsx +75 -4
  118. package/docs/navigation/tabs.tsx +149 -0
  119. package/docs/roadmap/antd-parity.md +35 -34
  120. package/docs/roadmap/parity-audit-data-entry.md +196 -172
  121. package/docs/roadmap/parity-audit-layout-navigation-general.md +304 -310
  122. package/docs/roadmap/parity-backlog.md +8 -10
  123. package/docs/showcase/acme-portal.tsx +7 -0
  124. package/package.json +28 -64
  125. package/scripts/ui-audit.mjs +18 -5
  126. package/scripts/visual-audit-rules.mjs +0 -7
  127. package/scripts/visual-audit.mjs +6 -27
  128. package/dist/components/data-entry/date-range-picker.d.ts +0 -11
  129. package/dist/components/data-entry/date-range-picker.js +0 -349
  130. package/dist/components/data-entry/month-picker.d.ts +0 -10
  131. package/dist/components/data-entry/month-picker.js +0 -241
  132. package/dist/components/data-entry/month-range-picker.d.ts +0 -10
  133. package/dist/components/data-entry/month-range-picker.js +0 -301
  134. package/dist/components/navigation/context-menu.d.ts +0 -21
  135. package/dist/components/navigation/context-menu.js +0 -149
  136. package/dist/components/navigation/menubar.d.ts +0 -21
  137. package/dist/components/navigation/menubar.js +0 -136
  138. package/dist/components/navigation/navigation-menu.d.ts +0 -10
  139. package/dist/components/navigation/navigation-menu.js +0 -91
  140. package/dist/components/ui/context-menu.d.ts +0 -1
  141. package/dist/components/ui/context-menu.js +0 -2
  142. package/dist/components/ui/date-range-picker.d.ts +0 -1
  143. package/dist/components/ui/date-range-picker.js +0 -2
  144. package/dist/components/ui/menubar.d.ts +0 -1
  145. package/dist/components/ui/menubar.js +0 -2
  146. package/dist/components/ui/navigation-menu.d.ts +0 -1
  147. package/dist/components/ui/navigation-menu.js +0 -2
  148. package/docs/FRAME-A11Y-CI.md +0 -349
  149. package/docs/data-entry/date-range-picker.tsx +0 -175
  150. package/docs/data-entry/month-picker.tsx +0 -101
  151. package/docs/data-entry/month-range-picker.tsx +0 -115
  152. package/docs/navigation/context-menu.tsx +0 -128
  153. package/docs/navigation/menubar.tsx +0 -141
  154. package/docs/navigation/navigation-menu.tsx +0 -158
@@ -1,91 +0,0 @@
1
- "use client";
2
- import { jsx, jsxs } from "react/jsx-runtime";
3
- import * as React from "react";
4
- import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
5
- import { ChevronDown } from "lucide-react";
6
- import { cn } from "../../lib/utils.js";
7
- const NavigationMenu = NavigationMenuPrimitive.Root;
8
- const NavigationMenuList = React.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(
9
- NavigationMenuPrimitive.List,
10
- {
11
- ref,
12
- "data-slot": "navigation-menu-list",
13
- className: cn("ui-navigation-menu-list", className),
14
- ...props
15
- }
16
- ));
17
- NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
18
- const NavigationMenuItem = React.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(
19
- NavigationMenuPrimitive.Item,
20
- {
21
- ref,
22
- "data-slot": "navigation-menu-item",
23
- className: cn("ui-navigation-menu-item", className),
24
- ...props
25
- }
26
- ));
27
- NavigationMenuItem.displayName = NavigationMenuPrimitive.Item.displayName;
28
- const NavigationMenuTrigger = React.forwardRef(({ className, children, ...props }, ref) => /* @__PURE__ */ jsxs(
29
- NavigationMenuPrimitive.Trigger,
30
- {
31
- ref,
32
- "data-slot": "navigation-menu-trigger",
33
- className: cn("ui-navigation-menu-trigger", className),
34
- ...props,
35
- children: [
36
- children,
37
- /* @__PURE__ */ jsx(ChevronDown, { className: "ui-navigation-menu-trigger-icon", "aria-hidden": "true" })
38
- ]
39
- }
40
- ));
41
- NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
42
- const NavigationMenuContent = React.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(
43
- NavigationMenuPrimitive.Content,
44
- {
45
- ref,
46
- "data-slot": "navigation-menu-content",
47
- className: cn("ui-navigation-menu-content", className),
48
- ...props
49
- }
50
- ));
51
- NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
52
- const NavigationMenuLink = React.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(
53
- NavigationMenuPrimitive.Link,
54
- {
55
- ref,
56
- "data-slot": "navigation-menu-link",
57
- className: cn("ui-navigation-menu-link", className),
58
- ...props
59
- }
60
- ));
61
- NavigationMenuLink.displayName = NavigationMenuPrimitive.Link.displayName;
62
- const NavigationMenuIndicator = React.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(
63
- NavigationMenuPrimitive.Indicator,
64
- {
65
- ref,
66
- "data-slot": "navigation-menu-indicator",
67
- className: cn("ui-navigation-menu-indicator", className),
68
- ...props
69
- }
70
- ));
71
- NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName;
72
- const NavigationMenuViewport = React.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(
73
- NavigationMenuPrimitive.Viewport,
74
- {
75
- ref,
76
- "data-slot": "navigation-menu-viewport",
77
- className: cn("ui-navigation-menu-viewport", className),
78
- ...props
79
- }
80
- ));
81
- NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName;
82
- export {
83
- NavigationMenu,
84
- NavigationMenuContent,
85
- NavigationMenuIndicator,
86
- NavigationMenuItem,
87
- NavigationMenuLink,
88
- NavigationMenuList,
89
- NavigationMenuTrigger,
90
- NavigationMenuViewport
91
- };
@@ -1 +0,0 @@
1
- export * from "../navigation/context-menu.js";
@@ -1,2 +0,0 @@
1
- "use client";
2
- export * from "../navigation/context-menu.js";
@@ -1 +0,0 @@
1
- export * from "../data-entry/date-range-picker.js";
@@ -1,2 +0,0 @@
1
- "use client";
2
- export * from "../data-entry/date-range-picker.js";
@@ -1 +0,0 @@
1
- export * from "../navigation/menubar.js";
@@ -1,2 +0,0 @@
1
- "use client";
2
- export * from "../navigation/menubar.js";
@@ -1 +0,0 @@
1
- export * from "../navigation/navigation-menu.js";
@@ -1,2 +0,0 @@
1
- "use client";
2
- export * from "../navigation/navigation-menu.js";
@@ -1,349 +0,0 @@
1
- # Per-frame accessibility & coverage CI
2
-
3
- They render every catalog frame in real Chromium and hold the line on accessibility, responsive geometry and contract coverage. Static checks (`audit:examples`, source regexes) cannot see rendered colour/layout/ARIA — these gates do.
4
-
5
- ## The three gates
6
-
7
- | Script | `pnpm` | Drives | Blocking? |
8
- | ----------------------------- | ---------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
9
- | `scripts/check-frame-axe.mjs` | `check:frame-axe` | axe over **every frame** at desktop 1440×900 + mobile-sm 375×667 | **Preview-chrome: yes.** Component/demo: allowlisted (regression = fail) |
10
- | `scripts/frame-geometry.mjs` | `check:frame-geometry` | overflow + clipped-control sweep across **320·375·390·768·1024·1280·1440·1920** | Allowlisted (regression = fail) |
11
- | `scripts/frame-coverage.mjs` | `check:frame-coverage` | component-inventory ↔ frame ↔ contract-axis cross-check (browser-free) | Report-only (`--strict` fails on any zero-frame component) |
12
-
13
- `check:frame-geometry` works identically for responsive overflow (see its own header comment); `check:frame-coverage` is covered briefly at the end and fully in [FRAME-COVERAGE-STANDARD.md](./FRAME-COVERAGE-STANDARD.md).
14
-
15
- ### What geometry counts as a "clipped control"
16
-
17
- A control the **user cannot bring into the frame** — not merely one whose box currently sticks out. Everything inside a deliberately scrollable surface sticks out by design (a `DataTable`'s `.ui-data-table-scroll` at 320px, a `FilterBar overflow="scroll"` strip, a tabs list), and those controls are reachable: a control entirely out of the scrollport is scrolled fully in the moment it takes keyboard focus, and a partially visible one keeps its focus ring on screen (Chromium's `CenterIfNeeded` focus alignment does not move a partially visible target — browser behaviour, and still inside 2.4.7 / 2.4.11 AA). So an out-of-frame focusable is re-probed: the sweep scrolls **only** its user-scrollable ancestors (computed `overflow: auto|scroll|overlay` that actually has scroll room — never `hidden`/`clip`, which no user can scroll) and re-measures.
18
-
19
- ## Why a per-frame gate, not the old hand-picked checks
20
-
21
- - `check:contrast` (`scripts/check-contrast.mjs`) — a hand-picked set of ~6 pages checked for WCAG-AA text contrast. It never saw the other ~100 component frames. - `audit:examples` (`pnpm audit docs`) — a static source-regex linter. It can catch a hard-coded hex value but cannot see what actually renders: computed contrast, DOM landmark structure, focus order, or anything a Radix portal produces at runtime.
22
-
23
- ## Chrome vs component vs overlay: three independently-tracked scopes
24
-
25
- `check:frame-axe` runs axe **twice** per frame/viewport, and a **third** time on the frames that declare an open step:
26
-
27
- - **Preview chrome** = the zoom / dimension **toolbar** (`.demo-block-toolbar`, `preview/src/demo-block.tsx`). We own this. It **must be 0** — a chrome violation fails the build.
28
- - **Component/demo** = everything else, in the frame's **default** state (the rendered frame _and_ its Radix portals). Allowlisted in `baseline.component`.
29
- - **Overlay** = the same scope re-measured **after opening an overlay** — see below. Allowlisted separately in `baseline.overlay`.
30
-
31
- ### The overlay scope: opening the thing before measuring it (#355)
32
-
33
- The gate used to load each frame in its default state and scan once, so every axe rule whose _condition only exists while an overlay is open_ — `aria-hidden-focus` first among them — was **outside its field of view**, not merely un-triggered. 164 frames × 2 viewports reported clean while `aria-hidden-focus` fired on the app background the moment a `Select` or `DropdownMenu` opened in a consumer app.
34
-
35
- A frame opts in **declaratively**, from the demo itself:
36
-
37
- ```tsx
38
- // the trigger itself, when it forwards DOM props
39
- <DropdownMenuTrigger asChild>
40
- <Button data-axe-open …>…</Button>
41
- </DropdownMenuTrigger>
42
-
43
- // the region that owns the trigger, when the component renders it internally
44
- <CardContent data-axe-open>
45
- <FormField id="status" label="状態"><Select … /></FormField>
46
- </CardContent>
47
-
48
- // right-click, for the one overlay that opens on no other gesture
49
- <ContextMenuTrigger data-axe-open="contextmenu" …>
50
- ```
51
-
52
- What the gate does with it, per frame/viewport, **after** the two default-state scans (opening an overlay is destructive to the state those measure):
53
-
54
- 1. Take the **first** `[data-axe-open]` on the page. No declaration ⇒ the frame skips this scope entirely and costs nothing.
55
- 2. Press **Escape** up to three times while any overlay is mounted. Several demos deliberately render one open at rest, and a modal one makes the rest of the page inert — so the trigger is unreachable until it is dismissed. This also makes the measurement a real **closed → open transition** rather than whatever the demo happened to leave mounted.
56
- 3. Resolve the click target: the declaring element itself when it matches `button, [role=button], [role=combobox], [role=menuitem], a[href], summary`, otherwise the first such control inside it. (`DatePicker`, the data-driven `Select` and `SearchSelect` own their trigger DOM and forward no `data-*` to it — hence the "declare on the region" form. `DatePicker`'s combobox `<input>` opens the calendar on click, which is what gets pressed there.)
57
- 4. Click (or right-click), then wait until **one more** overlay is mounted (`[data-radix-popper-content-wrapper], [role=dialog], [role=alertdialog], [role=menu], [role=listbox]`) plus a 400 ms animation settle — never a bare timer.
58
- 5. Re-run axe with the same scope as the component pass and record it under `baseline.overlay`.
59
-
60
- A declaration whose overlay never opens is an **infrastructure error** (blocking), not a silent skip: a broken declaration must not read as a clean frame.
61
-
62
- **Frames declaring an open step today** (the overlay families named in #355): `data-entry-select`, `data-entry-select-matrix` (SearchSelect), `data-entry-date-picker`, `data-display-popover`, `navigation-dropdown-menu`, `navigation-context-menu`, `feedback-dialog`, `feedback-sheet`.
63
-
64
- **Cost**: the overlay pass only runs on declaring frames, so it is ~8 frames × 2 viewports, measured at **+~55 s on a ~9.5 min local sweep (~10 %)**. Both viewports are kept for now; if the declaring set grows, narrow `OVERLAY_VIEWPORTS` in the script to `["desktop"]` — an overlay is portalled to `<body>` and its aria-hidden background is the same tree at either width.
65
-
66
- **What is still outside this scope** — deliberately, and worth knowing before trusting a green run:
67
-
68
- - Only the **first** declaration per frame is exercised. A frame with several distinct overlays (the Sheet demo has five) is measured on one of them.
69
- - Only **one step deep**: submenus (`DropdownMenuSub`), an overlay opened _from inside_ another overlay, and the state _after_ a selection are not reached.
70
- - **Keyboard-only opening** is not exercised — the gate clicks. A trigger that opens on click but not on `Enter`/`Space` would pass.
71
- - The overlay's own **focus trap and focus order** are still not asserted by axe here (`focus-order-semantics` needs the focus to actually move); `src/components/__tests__/overlay-hidden-background.a11y.test.tsx` remains the DOM-level guard for the aria-hidden/inert contract, and it is what fails first if the `inert` mirror in `src/components/general/inert-background.ts` regresses in jsdom.
72
- - Frames with **no declaration** (156 of 164) are unchanged: default state only.
73
-
74
- ## The component allowlist — and how it shrinks to zero
75
-
76
- `scripts/frame-axe.baseline.json` is the allowlist of **pre-existing** component violations, keyed `frame → [axe-rule-id]` — the **set of violation types** still firing on each frame (union across desktop + mobile). We key on rule _presence_, not exact node counts, because axe counts jitter run-to-run as a Radix portal (Dialog/Popover/Sheet) opens or closes — an exact-count gate would be flaky. `--update-baseline` unions **two passes** by default (`AXE_RUNS`) so an intermittently-mounted overlay is captured.
77
-
78
- - **fails** if a frame gains a rule **not** in its baseline set (a genuinely new violation type);
79
- - **passes** rule sets equal to or a subset of baseline, printing exactly what remains (nothing hidden);
80
-
81
- The baseline can **only shrink**.
82
-
83
- ```sh
84
- git pull # get the merged component fixes
85
- pnpm check:frame-axe --update-baseline # re-snapshot: rule sets only shrink
86
- git add scripts/frame-axe.baseline.json && git commit -m "chore(a11y): tighten frame-axe baseline"
87
- ```
88
-
89
- There is **no blanket suppression**: a documented axe false positive would be an explicit per-rule entry with a comment, never a mute.
90
-
91
- `baseline.overlay` is the same shape and obeys the same rule, for the overlay-open scope. It is only rewritten for frames whose open step **actually ran** — a frame that stopped declaring one has not been proven clean, it has been left unmeasured.
92
-
93
- **Keys that are not violation data are preserved (#356).** The baseline also carries human notes — `scopeGap` records what the gate cannot see and which violations are deliberately unfixed. `--update-baseline` used to rebuild the file from scratch and delete them without a word. It now regenerates only the keys it owns (`generatedAt`, `note`, `component`, `overlay`), carries every other key over verbatim, and **names them in its log line** (`carried over: scopeGap`) so a disappearing key cannot go unnoticed.
94
-
95
- `0` chrome violations, `0` regressions. See [Remaining baseline debt](#remaining-baseline-debt--the-12-frames) below for exactly what's left and the path to zero.
96
-
97
- ## Running it locally
98
-
99
- ### Prerequisites
100
-
101
- `playwright` and `@axe-core/playwright` are **optional peers** — `check:frame-axe` detects a missing/unbuilt peer and skips with a warning instead of failing the build (so a browser-less environment, e.g. a docs-only contributor, never gets blocked). To actually run it:
102
-
103
- ```sh
104
- pnpm install
105
- pnpm exec playwright install chromium # local dev — downloads Playwright's managed Chromium
106
- # CI uses: pnpm exec playwright install --with-deps chromium (adds OS-level deps)
107
- ```
108
-
109
- `frame-harness.mjs` looks for a Chromium binary at `PLAYWRIGHT_CHROMIUM_EXECUTABLE`, defaulting to a **CI-only** path (`/opt/pw-browsers/chromium-1228/chrome-linux64/chrome`). That path won't exist on a laptop — when it's absent the harness falls back to Playwright's own managed browser (from `playwright install` above), so a plain local checkout works without setting anything.
110
-
111
- ### Commands
112
-
113
- | Command | What it does |
114
- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
115
- | `pnpm check:frame-axe` | The gate: 1 pass over every frame × 2 viewports, compares to the baseline, exits non-zero on fail |
116
- | `AXE_FRAMES_LIMIT=8 pnpm check:frame-axe` | Quick local smoke — only the first 8 frames (manifest order) |
117
- | `pnpm check:frame-axe --format json` | Machine-readable result on stdout (same exit-code semantics) — no colored human report |
118
- | `pnpm check:frame-axe --update-baseline` | Regenerate the allowlist (2-pass union by default) — see below |
119
- | `AXE_RUNS=3 pnpm check:frame-axe --update-baseline` | More union passes, for a component with flaky portal timing |
120
- | `AXE_SHARD=2/4 pnpm check:frame-axe` | Run only shard 2 of 4 (1-based) for CI fan-out — see below |
121
- | `pnpm check:frame-axe http://localhost:6008` | Point at an already-running preview instead of building+serving one |
122
-
123
- #### Sharding the sweep (`AXE_SHARD=i/n`)
124
-
125
- `AXE_SHARD=2/4` runs only the frames of shard 2 of 4. The partition is taken over frame ids sorted **by code point**, not in manifest/directory order, so the same tree always yields the same partition, every frame lands in exactly one shard, and the union of all `n` shards is exactly the full sweep. Unset (the default) leaves the frame list untouched — sharding changes nothing until you ask for it.
126
-
127
- Two things are deliberately _not_ the same under a shard, because a shard cannot know them:
128
-
129
- - **"Baseline rule no longer fires" (shrink hints) is skipped.** The baseline is keyed per frame, so _new_ rule detection stays exact on a partial run — but a frame this shard never visited looks identical to a frame that got fixed. Only a full sweep can tell those apart.
130
- - **`--update-baseline` refuses to run** with `AXE_SHARD` set (exit 2). Writing the baseline from a fraction of the frames would delete every frame the shard did not visit.
131
-
132
- `AXE_FRAMES_LIMIT` truncates the list instead of partitioning it; it is a local smoke-test knob, not a fan-out mechanism.
133
-
134
- If no preview server answers at the base URL (`http://localhost:6008` by default), the script builds one itself: `pnpm preview:build` then serves the static output with `vite preview` (`ensurePreviewServer` in `scripts/frame-harness.mjs`) — deterministic and stable under a long headless sweep, unlike the dev server's per-request recompilation. If a `pnpm preview` dev server (or a remote base) is already reachable, it's reused as-is and never rebuilt.
135
-
136
- ### Reading the output
137
-
138
- Terminal report (human mode) has three blocks:
139
-
140
- ```
141
- Preview-chrome axe (blocking — must be 0):
142
- ✓ 0 chrome violations across all 164 frame(s).
143
-
144
- Component/demo axe (allowlisted — baseline may only shrink):
145
- 0 component violation node(s) remaining across 0 frame(s).
146
- ✓ no new component violation types (rule-set within baseline).
147
-
148
- Overlay axe (frames declaring data-axe-open — baseline may only shrink):
149
- 8 frame(s) opened an overlay before scanning; 128 violation node(s) while open.
150
- ✓ no new overlay violation types (rule-set within baseline).
151
- ```
152
-
153
- - A **red `✗ <rule>`** under chrome is always blocking.
154
- - A **red `✗ N NEW violation-type(s) not in baseline`** under component means a frame gained a
155
- rule the baseline didn't have — that's the actual CI failure mode for a component regression.
156
- - A **red `✗ N NEW violation-type(s) not in the overlay baseline`** is the same failure for the
157
- overlay-open state — that is what turns red when, say, the `inert` background patch regresses
158
- and `aria-hidden-focus` comes back.
159
- - A **yellow `↓ shrink hint`** means a baseline rule no longer fires anywhere it's listed — free
160
- baseline tightening available via `--update-baseline`. Not emitted under `AXE_SHARD`.
161
-
162
- ### Evidence JSON
163
-
164
- Every non-`--update-baseline` run writes `audit-evidence/frame-axe/frame-axe-results.json`
165
- (gitignored — a CI artifact, uploaded by `frame-a11y.yml` under the `frame-axe-evidence` name).
166
- Shape:
167
-
168
- ```jsonc
169
- {
170
- "generatedAt": "2026-07-13T00:45:34.614Z",
171
- "base": "http://localhost:6008",
172
- "summary": {
173
- "status": "ok", // "ok" | "fail"
174
- "frames": 164,
175
- "shard": null, // "i/n" when AXE_SHARD is set
176
- "viewports": ["desktop", "mobile"],
177
- "chromeViolations": 0, // total chrome violation NODE count
178
- "componentViolations": 0, // total component violation NODE count (reporting only)
179
- "componentRegressions": 0, // rules present now but not in the baseline
180
- "overlayFrames": 8, // frames whose declared open step ran
181
- "overlayViolations": 128, // total NODE count measured with an overlay open
182
- "overlayRegressions": 0, // overlay rules present now but not in baseline.overlay
183
- "infrastructureErrors": 0, // frames that failed to load / axe crashed
184
- },
185
- "chrome": {/* rule → { impact, nodes, frames: ["<frameId>@<viewport>", …] } */},
186
- "componentCurrent": {/* frameId → viewport → ruleId → node count, this run */},
187
- "regressions": [/* { frame, rule } — new rules, the actual gate failure */],
188
- "shrinkHints": [/* { frame, rule } — baseline rules that no longer fire */],
189
- "overlayCurrent": {/* frameId → viewport → ruleId → node count, with the overlay OPEN */},
190
- "overlayFrames": [/* frame ids whose declared open step ran */],
191
- "overlayRegressions": [/* { frame, rule } — new rules in the overlay-open state */],
192
- "overlayShrinkHints": [/* { frame, rule } */],
193
- "infraErrors": [/* { frame, viewport, message } — includes "overlay open: …" failures */],
194
- "results": [/* per-frame { id, viewports: { desktop: { chrome, component, overlay? }, … } } */],
195
- }
196
- ```
197
-
198
- Node counts are recorded for triage but are **not** what the gate compares — the gate compares
199
- rule _presence_ per frame (`currentRules` vs `baseline.component[frame]`), because node counts
200
- jitter with portal open/close timing (see "The component allowlist" above). The evidence file does
201
- **not** carry the underlying axe `nodes[].any[].data` (e.g. the exact contrast ratio or CSS
202
- selector) — `scan()` in `check-frame-axe.mjs` only keeps `{ id, impact, nodes: v.nodes.length }`.
203
- To see the raw axe payload for a specific violation while triaging, run `@axe-core/playwright`
204
- directly against `http://localhost:6008/frame/<id>` in a small throwaway script, or use the
205
- Playwright Inspector / browser devtools with the `axe-core` extension against the same URL.
206
-
207
- ### Regenerating / shrinking the baseline after a component fix
208
-
209
- 1. Fix the root cause in the component (or the docs demo, if the violation is demo-content-only —
210
- see the categories below for which is which).
211
- 2. `pnpm check:frame-axe --update-baseline` — re-snapshots every frame's current rule set (2-pass
212
- union). Frames that are now clean are **removed** from the JSON entirely; frames whose rule set
213
- shrank keep only what still fires.
214
- 3. `git diff scripts/frame-axe.baseline.json` — confirm the diff is a **strict shrink** (no rule
215
- should reappear on a frame that previously didn't have it — if one does, you introduced a new
216
- violation, not fixed an old one).
217
- 4. Commit the baseline alongside the fix in the same PR.
218
-
219
- `--update-baseline` also preserves every non-violation key in the file verbatim (see #356 above) and refuses to run under `AXE_SHARD`.
220
-
221
- `AXE_FRAMES_LIMIT` + `--update-baseline` together only touch the frames actually run — the merge
222
- logic in `check-frame-axe.mjs` overwrites `runFrameIds` and leaves every other frame's baseline
223
- entry untouched, so a scoped re-snapshot (e.g. after fixing just `Select`) never silently drops
224
- unrelated frames from the allowlist.
225
-
226
- ## Violation categories & how each was root-caused (reference)
227
-
228
- The original #157 audit's 101-frame baseline collapsed to 12 through structural fixes, not
229
- whack-a-mole per-frame patches. Each category below is a pattern — recognize it the next time a
230
- new frame trips the same rule.
231
-
232
- | Category | axe rule(s) | Root cause | Fix pattern |
233
- | ------------------------------------------ | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
234
- | **Heading order** | `heading-order` | Every docs demo nests `PageContainer`'s `<h1>` directly around sibling `<Card>` sections whose `CardTitle` defaulted to `level={3}` (`<h3>`) — skipping `<h2>` (91 frames). | AST codemod bumped every **top-level** `CardTitle` to `level={2}`; nested Card-in-Card titles correctly kept the `h3` default (`CardTitle`'s own jsdoc already documents this contract). Commit `030e82a`. |
235
- | **Landmark — main** | `landmark-one-main`, `landmark-no-duplicate-main`, `landmark-main-is-top-level` | `preview/src/frame-main.tsx` hardcoded a `<main>` around every frame. A story that renders its own page shell (`AppShell`/`AuthShell`, which already emit their own `<main>`) got a nested/duplicate main. Story metadata can't disambiguate — every catalog entry defaults to `layout: "fullscreen"` regardless of content. | `FrameLandmark` (`preview/src/frame-main.tsx`) detects it **after mount**: `useLayoutEffect` checks `node.querySelector('main, [role="main"]')` — stays a plain `<div>` if the subtree already owns a main, otherwise renders the real `<main>` **tag** itself (not `role="main"` — several axe landmark checks walk ancestors by native tag name, not computed role). Commit `030e82a`, `281c8d4`. |
236
- | **Landmark — banner (chrome)** | `landmark-no-duplicate-banner` | The demo toolbar was a candidate `<header>`, which resolves to an implicit `banner` landmark whenever the rendered story also provides its own (`AppShell` demos). | `demo-block.tsx`'s toolbar is a plain `<div className="demo-block-toolbar">`, never a `<header>` — it's preview chrome, not page banner content (see the comment right above it in the file). |
237
- | **Landmark — uniqueness** | `landmark-unique` | `Pagination`, `Breadcrumb` (standalone + `PageContainer`'s built-in slot), and `Carousel` each hardcoded ONE localized `aria-label` with no override. Fine for a real screen (one instance); the component-gallery docs stack several instances of the same component on one page, so same-role + same-name landmarks become indistinguishable in a screen-reader landmark list (WCAG 2.4.1 / 1.3.1). | Added an `aria-label` override prop (`PaginationProp`, `BreadcrumbProps`, `PageContainer`'s `breadcrumbAriaLabel`); every repeated docs instance now passes a distinguishing label. Also fixed `PageContainer`'s breadcrumb slot to route its label through `t()` instead of a hardcoded English `"Breadcrumb"` literal (an i18n-contract bug found in the same pass). Commit `741ccf5`. |
238
- | **Scrollable region focusable** | `scrollable-region-focusable` | An `overflow: auto`/`overflow-x: auto` container was in the tab order for mouse users but unreachable by keyboard (WCAG 2.1.1). Hit three places: `.demo-block-canvas`/`.demo-block-frame` (the preview harness's own scroll wrappers), and the `Table` primitive's/`DataTable`'s/`ScrollArea`'s overflow wrappers. | Add `tabIndex={0}` to the scrolling element — **no `role="region"`**: a landmark role there would break `landmark-*-is-top-level` for any story that renders its own regions (e.g. `AppShell` demos). See the inline comments in `demo-block.tsx` (`.demo-block-canvas`) and `src/components/data-display/table.tsx`. Commits `030e82a`, `8675570`. |
239
- | **List semantics** | `listitem` | `docs/data-display/list-row.tsx` rendered two `<ListRow as="li">` siblings directly inside `CardContent` (a plain `<div>`) — axe requires an `<li>`'s parent to actually be a list container (`ul`/`ol`/`role="list"`). `ListRow`'s own jsdoc already documents `as="li"` for "when the rows are a semantic list" — the demo just hadn't supplied the wrapping list it implied. | Wrap the `<li>` rows in a real `<ul>` in the demo. Commit `30d22f1`. |
240
- | **Accessible name — button** | `button-name` | A `<button>` (native or a `SelectTrigger`) with no discernible text (WCAG 4.1.2). | Case-by-case: give the control real text, an `aria-label`, or route it through `FormField` (see below). Remaining `button-name` entries all trace to the `SelectTrigger`/`DataSelect` prop-forwarding gap fixed by #177 — see [Remaining baseline debt](#remaining-baseline-debt--the-12-frames). |
241
- | **Accessible name — form control / label** | (implicit via `FormField` wiring, WCAG 1.3.1/4.1.2) | A control inside `FormField` was named only via `aria-labelledby`, which some AT/axe paths don't resolve as reliably as a direct name. | `FormField` (`src/components/data-entry/form-field.tsx`) now **also** injects a redundant `aria-label` mirroring the visible label text (when it's plain text), alongside its existing `aria-labelledby` wiring — belt-and-suspenders, only when the child hasn't already set its own `aria-label`. Commit `8675570`. |
242
- | **Accessible name — dialog** | `aria-dialog-name` / `aria-hidden-focus` | A `role="dialog"` (Popover in `modal` mode) needs a discernible name; a modal Popover **shown open at rest** in a demo (`Popover modal defaultOpen`) also puts Radix's `aria-hide` on everything outside the portal, which can hide the page's own `<main>`/`<h1>` from the accessibility tree scan. | Label the titled/modal Popover demos with `aria-label` on `PopoverContent`. The `aria-hidden-focus`/`landmark-one-main`/`page-has-heading-one` combination on an **open-at-rest modal demo** is the deferred exception — see below. Commit `8675570`. |
243
- | **Color contrast** | `color-contrast` | `Calendar`'s selected-day ghost `<button>` kept dark text on the blue fill through hover/focus (insufficient contrast on the selected date). | Force `text-primary-foreground` on the day `<button>` through hover/focus states. Commit `8675570`. Some `color-contrast` entries remain on `text-muted-foreground` small text — tracked, not yet fixed (see below and [color-extensibility.md](./roadmap/color-extensibility.md)). |
244
- | **Stray heading from empty state** | `heading-order` | `DataTable`'s built-in empty state rendered its "no rows" message as an `<h3>` (`EmptyState`'s default `titleAs`), injecting a heading into the outline for a state message, not a section. | `DataTable`'s empty state now renders `titleAs="p"` (plain text) — `EmptyState`/`CardTitle` both expose `titleLevel`/`titleAs` (`level`/`as` for `CardTitle`) precisely so callers can pick outline position without changing visual size. Commit `8675570`. |
245
-
246
- ## Remaining baseline debt — the 12 frames
247
-
248
- As of `frame-axe.baseline.json` (`generatedAt: 2026-07-13`), 12 frames still carry an allowlisted
249
- component violation. Grouped by root cause (per the tracking note in commit `8675570`):
250
-
251
- | Frame(s) | Rule(s) | Why it's still open | Path to zero |
252
- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
253
- | `data-entry-select`, `data-entry-select-matrix`, `feedback-sheet`, `navigation-toolbar` | `button-name` (+ `feedback-sheet` also `color-contrast`) | All four demos use `Select`/`SelectTrigger`. `#177` (merged, commit `1ef05be`) shipped the `DataSelect` prop/aria-forwarding + `SelectTrigger showIndicator` fix this was tracked against, but the axe baseline was **not re-verified/re-snapshotted** against `main` after that PR merged. | Run `pnpm check:frame-axe --update-baseline` on current `main`; if `#177` closed the underlying gap, these 4 entries drop out automatically. If any remain, re-triage against the current `SelectTrigger`/`FormField` DOM. |
254
- | `navigation-tabs` | `color-contrast` | `#176` (merged, commit `78c1c3b`) fixed `Tabs`' scrollable-overflow + disabled-fallback selection; the `color-contrast` entry here was tracked against that same PR but likewise **not re-verified** since. | `pnpm check:frame-axe --update-baseline` after confirming the fix; re-triage if it persists. |
255
- | `data-entry-transfer`, `feedback-sheet` | `color-contrast` | Both demos use `text-muted-foreground` on small text (`transfer.tsx:83,101,121`, `sheet.tsx:226`). This is the same **default-theme contrast tradeoff** already tracked in [`docs/roadmap/color-extensibility.md`](./roadmap/color-extensibility.md) — resolving it is a brand-colour decision (darken the token or add a dedicated `--text-muted` variant), deliberately deferred pre-release rather than shipped as an unflagged palette shift. | Land the token decision in `color-extensibility.md`, then re-run `--update-baseline`. |
256
- | `data-entry-calendar` | `landmark-unique` | `Calendar` wraps `react-day-picker` (`^10.0.1`), which renders its **own internal `<nav>`** for month navigation inside every `DayPicker` instance. The docs demo renders 4 `Calendar` instances on one page (each with a distinguishing container `aria-label`, but the _nested_ `nav` isn't currently overridable through this wrapper's `classNames` config) — 4 identical unnamed `nav` landmarks on one page. | Needs either upstream `react-day-picker` support for a per-instance nav label, or wrapping its nav slot with a custom, labelled replacement in `src/components/data-entry/calendar.tsx`. |
257
- | `layout-split-pane` | `landmark-unique` | `SplitPane` (`src/components/layout/split-pane.tsx`) renders a bare `<aside className="ui-split-pane-aside">` with **no `aria-label` prop at all**. `docs/layout/split-pane.tsx` renders 4 `SplitPane` instances on one page → 4 identical unnamed `complementary` landmarks. | Add an `aria-label`/`asideAriaLabel` override prop to `SplitPane`, same pattern as `Pagination`/`Breadcrumb` (commit `741ccf5`), then give each docs instance a distinguishing label. |
258
- | `data-display-popover` | `aria-hidden-focus`, `landmark-one-main`, `page-has-heading-one` | `docs/data-display/popover.tsx` (line ~198) renders `<Popover modal defaultOpen>` — a modal Popover **shown open at rest** so the demo illustrates the modal behavior without a click. Radix's modal mode `aria-hide`s everything outside the portal while open, which hides the frame's own `<main>`/`<h1>` from axe's accessibility-tree scan (and leaves focusable content behind the hidden boundary). | Either stop shipping this specific demo `defaultOpen` (illustrate modal-ness a different way, e.g. a screenshot/GIF or a "click to open" note) or accept it as a **known, deliberate** demo-only artifact of showing a Radix modal-trap mid-interaction and add a per-rule documented exception (not a blanket mute) once the maintainers decide which. |
259
- | `layout-resizable-panel` | `scrollable-region-focusable` | `docs/layout/resizable-panel.tsx:72` — a demo panel with an inline `style={{ overflowY: "auto" }}`, added directly in the doc content, not through a primitive that already carries the `tabIndex={0}` fix. | Add `tabIndex={0}` to that panel's `<div>` in the docs demo (same fix pattern as `.demo-block-canvas`/`Table`, just not yet applied to this hand-rolled demo wrapper). |
260
- | `navigation-breadcrumb` | `scrollable-region-focusable` | `docs/navigation/breadcrumb.tsx:89,95` — two `<pre className="overflow-x-auto">` code-snippet blocks rendered directly in the demo body (not the footer "View code" panel, which is disabled in frame mode). | Add `tabIndex={0}` to the `<pre>` elements in the docs demo. |
261
- | `navigation-pagination` | `scrollable-region-focusable` | This one is a **real component gap**, not docs-only: `.ui-pagination-list` (`src/styles/layout.css`, the `#153`/`#165` no-wrap pagination CSS) sets `overflow-x: auto` so the page-number strip scrolls instead of wrapping to a second line — but `PaginationContent`'s `<ul>` (`src/components/navigation/pagination.tsx`) never received a `tabIndex={0}` to match. | Add `tabIndex={0}` to `PaginationContent`'s `<ul>` in `pagination.tsx` (mirrors the `Table`/`ScrollArea` fix), then `--update-baseline`. |
262
-
263
- Overall rule histogram across the 12 frames: `button-name` ×4, `color-contrast` ×3,
264
- `scrollable-region-focusable` ×3, `landmark-unique` ×2, `aria-hidden-focus` ×1,
265
- `landmark-one-main` ×1, `page-has-heading-one` ×1 (the last three all on the same
266
- `data-display-popover` frame).
267
-
268
- **Fastest path to a fully green baseline:** re-run `pnpm check:frame-axe --update-baseline` on
269
- current `main` first (it's plausible `#176`/`#177` already closed 5 of the 12 entries and nobody's
270
- re-snapshotted since) — then tackle the remaining `SplitPane`/`Calendar` landmark props, the two
271
- docs-only `tabIndex` additions, the one real `pagination.tsx` `tabIndex` fix, the muted-foreground
272
- token decision, and finally decide the modal-Popover-at-rest exception.
273
-
274
- ## Infra frame modes (issue #163)
275
-
276
- Any `/frame/<id>` accepts query params (implemented in `preview/src/frame-main.tsx`) so a route can
277
- be exercised across contract axes without editing per-component example content:
278
-
279
- | Param | Values | Effect |
280
- | --------- | --------------------------------------- | --------------------------------------------------------- |
281
- | `dir` | `ltr` \| `rtl` | flips logical CSS for the whole subtree (global RTL mode) |
282
- | `density` | `compact` \| `default` \| `comfortable` | drives `AppProvider` density |
283
- | `theme` | `light` \| `dark` | drives `AppProvider` theme |
284
- | `locale` | `ja` \| `vi` \| `en` \| `ar` … | drives `AppProvider` locale (BCP-47) |
285
-
286
- Example: `/frame/data-entry-select?dir=rtl&density=compact&theme=dark`.
287
-
288
- `check:frame-axe` itself always runs the default chrome (`ltr`/`default`/`light`/`ja`) — these
289
- params are for manual/exploratory testing (e.g. checking a component under RTL + dark in a real
290
- browser), not currently swept by the automated gate.
291
-
292
- ## Coverage tracker
293
-
294
- `check:frame-coverage` reads the public inventory (`mcp/src/data/components.ts`), the frames that
295
- exist (`docs/` tsx), and the declared ledger (`preview/frame-coverage.ledger.json`), then emits
296
- `docs/FRAME-COVERAGE-REPORT.md` + `audit-evidence/frame-coverage/coverage.json`. Every contract axis
297
- is `covered` / `N/A:<reason>` / **`UNTESTED`** — a missing axis is never a silent pass. Those axes
298
- are rolled up from the 14 contract dimensions the ledger tracks per export.
299
-
300
- `check:frame-coverage-ledger` is the enforcing gate: it regenerates the ledger from the real public
301
- surface, recomputes every dimension from the evidence, and fails on regression (coverage falling, a
302
- new export with no frame, a weakened viewport matrix, a growing geometry/axe baseline). Dimension
303
- totals climb toward full coverage (the #163 exit criterion). See
304
- [FRAME-COVERAGE-LEDGER.md](./FRAME-COVERAGE-LEDGER.md) and
305
- [FRAME-COVERAGE-STANDARD.md](./FRAME-COVERAGE-STANDARD.md).
306
-
307
- ## Adding a new component/frame without regressing the gate
308
-
309
- Before opening a PR that adds or meaningfully changes a `/frame/**` example:
310
-
311
- 1. **Give every top-level `Card` a `CardTitle level={2}`** when the frame sits directly under a
312
- `PageContainer` (`<h1>`) — nested Card-in-Card titles keep the `h3` default. Skipping this
313
- reintroduces `heading-order`.
314
- 2. **Don't hand-roll a scroll container.** If a demo needs `overflow: auto`/`overflow-x: auto`,
315
- add `tabIndex={0}` to the scrolling element and do **not** add `role="region"` (that turns it
316
- into a landmark and can trip `landmark-*-is-top-level` for stories with their own regions).
317
- Prefer reusing `Table`/`DataTable`/`ScrollArea`, which already carry this.
318
- 3. **Stacking more than one instance of a landmark-owning component** (`Pagination`, `Breadcrumb`,
319
- `SplitPane`'s `aside`, custom `<nav>`/`<aside>`/`<section aria-label>`) **on one page** — give
320
- each instance a distinguishing `aria-label` (use the `aria-label`/`breadcrumbAriaLabel` override
321
- props already on `Pagination`/`Breadcrumb`/`PageContainer` where they exist), or the frame trips
322
- `landmark-unique`.
323
- 4. **`<li>` needs a real list parent.** `ListRow as="li"` (or any bare `<li>`) must be inside a
324
- `<ul>`/`<ol>`/`role="list"` container, never a plain `<div>`.
325
- 5. **Every interactive control needs a discernible name.** Route form controls through `FormField`
326
- (it injects both `aria-labelledby` and a redundant `aria-label`); give icon-only buttons/triggers
327
- an explicit `aria-label`.
328
- 6. **A `role="dialog"` (Popover `modal`, custom dialogs) needs a name**, and avoid shipping a demo
329
- with a **modal** dialog `defaultOpen`/open-at-rest unless you've checked what it hides from the
330
- rest of the page (see the `data-display-popover` entry above) — prefer "click to open."
331
- 7. **Run the gate before pushing:** `pnpm exec playwright install chromium` (once) then
332
- `AXE_FRAMES_LIMIT=<n> pnpm check:frame-axe` scoped to your new/changed frame(s), or the full
333
- `pnpm check:frame-axe` if you have time. A brand-new frame with **zero** violations needs no
334
- baseline entry at all — it's only added if you're knowingly landing a pre-existing violation
335
- that a follow-up will fix (and even then, prefer fixing it before merge).
336
- 8. If you do land a frame with a tracked violation, add it to `scripts/frame-axe.baseline.json` via
337
- `--update-baseline` (never hand-edit the JSON) and note _why_ + the follow-up issue in the PR
338
- description — the baseline file's own `note` field explains it may only shrink from here.
339
- 9. **If the frame demos an overlay, declare its open step**: `data-axe-open` on the trigger (or on
340
- the region that owns it when the component renders the trigger itself), `data-axe-open="contextmenu"`
341
- for a context menu. Without it the frame is only ever measured closed, and the whole
342
- `aria-hidden-focus` family of rules cannot fire — see [the overlay scope](#the-overlay-scope-opening-the-thing-before-measuring-it-355).
343
- 10. Register the case you authored in `preview/frame-coverage.ledger.json` so
344
- `check:frame-coverage-ledger` stops reporting that dimension `UNTESTED`. You **cannot** hand-write
345
- a verdict — add an entry to the ledger's `cases` array (frame path + case heading + resolvable
346
- evidence paths + reviewer + HTTPS review link + ISO timestamp) and run
347
- `pnpm gen:frame-coverage-ledger`; the gate recomputes every cell from that evidence and rejects
348
- any verdict it cannot reproduce. See [FRAME-COVERAGE-LEDGER.md](./FRAME-COVERAGE-LEDGER.md) and
349
- [FRAME-COVERAGE-STANDARD.md](./FRAME-COVERAGE-STANDARD.md).