@godxjp/ui 24.0.0 → 24.1.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.
@@ -29,6 +29,23 @@ const DROPDOWN_MENU_PLACEMENT = {
29
29
  bottomStart: { side: "bottom", align: "start" },
30
30
  bottomEnd: { side: "bottom", align: "end" }
31
31
  };
32
+ class MenuCollectionBoundary extends React.Component {
33
+ state = { failed: false };
34
+ static getDerivedStateFromError() {
35
+ return { failed: true };
36
+ }
37
+ // Logged in EVERY build, not only development: "blank, with nothing in the console" is the half
38
+ // of gh#637 that cost the reporter a night of instrumenting `Object.prototype`.
39
+ componentDidCatch(error) {
40
+ console.error(
41
+ "[@godxjp/ui] DropdownMenu: the menu could not be built and was left empty; the rest of the application is unaffected. A menu's children must be collection nodes (DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuGroup, DropdownMenuSub) or components returning one \u2014 an icon or a text label belongs INSIDE an item, not beside it. Underlying error:",
42
+ error
43
+ );
44
+ }
45
+ render() {
46
+ return this.state.failed ? null : this.props.children;
47
+ }
48
+ }
32
49
  function borrowedTag(children, asChild) {
33
50
  return asChild ? React.Children.only(children) : null;
34
51
  }
@@ -357,7 +374,7 @@ function DropdownMenuContent({
357
374
  )
358
375
  ),
359
376
  children: [
360
- /* @__PURE__ */ jsx(Menu, { shouldFocusWrap: loop, autoFocus: openedByHover.current ? false : void 0, children }),
377
+ /* @__PURE__ */ jsx(MenuCollectionBoundary, { children: /* @__PURE__ */ jsx(Menu, { shouldFocusWrap: loop, autoFocus: openedByHover.current ? false : void 0, children }) }),
361
378
  arrow ? /* @__PURE__ */ jsx(OverlayArrow, { children: /* @__PURE__ */ jsx(
362
379
  "svg",
363
380
  {
@@ -601,7 +618,7 @@ function DropdownMenuSubContent({
601
618
  }
602
619
  )
603
620
  ),
604
- children: /* @__PURE__ */ jsx(Menu, { shouldFocusWrap: loop, children })
621
+ children: /* @__PURE__ */ jsx(MenuCollectionBoundary, { children: /* @__PURE__ */ jsx(Menu, { shouldFocusWrap: loop, children }) })
605
622
  }
606
623
  );
607
624
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "AUTO-GENERATED by scripts/gen-measurement-contract.mjs — do not edit. Read this instead of guessing: docs/MEASUREMENT-CONTRACT.md.",
3
- "version": "23.4.12",
3
+ "version": "24.1.0",
4
4
  "targetSize": {
5
5
  "standard": "WCAG 2.2 SC 2.5.8 Target Size (Minimum), level AA — 24×24 CSS px",
6
6
  "min": 24,
@@ -2239,6 +2239,11 @@
2239
2239
  }
2240
2240
 
2241
2241
  @media (max-width: 768px) {
2242
+
2243
+ .ui-topbar-item {
2244
+ padding-inline: var(--topbar-item-padding-inline-compact);
2245
+ }
2246
+
2242
2247
  .tb-chip-sep,
2243
2248
  .tb-search .kbd,
2244
2249
 
@@ -2250,8 +2255,12 @@
2250
2255
  width: auto;
2251
2256
 
2252
2257
  flex: 0 0 auto;
2253
- min-inline-size: var(--control-height);
2254
- min-width: var(--control-height);
2258
+ min-inline-size: var(--control-height-sm);
2259
+ min-width: var(--control-height-sm);
2260
+ inline-size: var(--control-height-sm);
2261
+ block-size: var(--control-height-sm);
2262
+ justify-content: center;
2263
+ padding-inline: 0;
2255
2264
  }
2256
2265
 
2257
2266
  .tb-search span {
@@ -101,6 +101,8 @@
101
101
  --topbar-center-compact-display: none;
102
102
 
103
103
  --topbar-item-padding-inline: var(--space-3);
104
+
105
+ --topbar-item-padding-inline-compact: var(--space-2);
104
106
  --topbar-item-gap: var(--space-2);
105
107
  --topbar-item-min-width: var(--control-height);
106
108
 
@@ -122,10 +122,30 @@ pnpm test # FULL suite — CI only, never from an agent loop
122
122
  pnpm preview:build # integration test: examples + docs must build — at most once, pre-PR
123
123
  pnpm audit # godxjp-ui-audit — 0 errors for touched files
124
124
  pnpm check:mcp-sync # MCP registry ↔ library export drift guard
125
+ pnpm check:frame-axe # WCAG 2.2 AA over every frame — see FRAME-A11Y-CI.md
125
126
  ```
126
127
 
127
128
  `pnpm verify` and `pnpm verify:release` run these together (verify:release also builds) — **both include the full suite, so both belong to CI.** Locally, run them at most once immediately before opening a PR, never inside an edit loop and never while other agents are working on the same machine. It needs `pnpm exec playwright install chromium` once locally; see [FRAME-A11Y-CI.md](./FRAME-A11Y-CI.md) for how to run/scope it, read the evidence, and regenerate its baseline after an accessibility fix.
128
129
 
130
+ ### Reproducing one screen at one width — the frame routes
131
+
132
+ Three gates and every bug report start here, so the addressing is written down rather than guessed
133
+ (godx-jp/id#639 burned three attempts on `?frame=…`, which is not a thing):
134
+
135
+ ```
136
+ pnpm preview # :6008
137
+ http://localhost:6008/isolate/<id> # the demo ALONE, at the real viewport — measure here
138
+ http://localhost:6008/frame/<id> # the same demo inside the device-preset chrome
139
+ ```
140
+
141
+ `<id>` is the demo's path under `docs/`, minus `.tsx`, with `/` turned into `-`:
142
+ `docs/layout/topbar.tsx` → `layout-topbar`, `docs/data-entry/date-picker.tsx` → `data-entry-date-picker`.
143
+ A file may override it with a `slug` in its frontmatter. Both routes accept
144
+ `?dir=rtl&density=compact&theme=dark&locale=ja`; `/frame/**` additionally takes `?preset=`/`?w=`/`?h=`/`?zoom=`.
145
+
146
+ For an axe measurement at a given width, drive `/isolate/<id>` with Playwright at that viewport —
147
+ that is what `scripts/topbar-collision-visual.mjs` and the other `test:visual:*` gates do.
148
+
129
149
  All gates are **self-contained** — no internal/external tooling package required. The eslint, prettier, and vitest setup live in the package (`eslint.config.js`, `prettier.config.mjs`, `vitest.config.ts`, `src/test/`), so a fresh checkout can lint/type-check/test without anything beyond the declared devDependencies.
130
150
 
131
151
  The app side additionally runs **`npm run ui:audit`** (the design-system linter) and must report 0 errors for touched files.
@@ -0,0 +1,135 @@
1
+ # Per-frame accessibility CI — `check:frame-axe`
2
+
3
+ One ruler, run in the repository that owns the CSS.
4
+
5
+ ## Why it exists (gh#643)
6
+
7
+ `scripts/visual-audit.mjs` carries **eight** rules. A consumer's nightly runs `@axe-core/playwright`
8
+ with **68**. Six of our eight are design-language opinions with no axe equivalent —
9
+ `oversaturated-accent`, `sibling-card-gap`, `row-content-starved`, `emoji-rendered`,
10
+ `alert-controls-misplaced`, `css-layers-missing` — and a design system **should** own those. The
11
+ other sixty (names, roles, `aria-*`, contrast, focus order, target size) were checked **nowhere in
12
+ this repository**, and only in a consumer that cannot fix the CSS, because the CSS is here.
13
+
14
+ gh#639 is what that costs. A topbar shipped; every gate here said green; `target-size` failed in
15
+ `godx-jp/id`'s nightly at 320px. And our own `target-size-min` could not have caught it at any
16
+ threshold — it measures a **painted box**, and that failure was an **obscured** target (axe:
17
+ `partiallyObscured`, 8×28). Two rulers, and the disagreement only ever surfaces downstream.
18
+
19
+ ## What it does
20
+
21
+ ```bash
22
+ pnpm check:frame-axe # every frame, 3 viewports
23
+ pnpm check:frame-axe -- --update # rewrite the baseline
24
+ pnpm check:frame-axe -- --scope=showcase # the 30 whole-page frames only, 30s
25
+ pnpm check:frame-axe -- /isolate/layout-topbar # one route, while fixing
26
+ ```
27
+
28
+ | | |
29
+ | --------- | --------------------------------------------------------------------------------- |
30
+ | tool | `@axe-core/playwright` |
31
+ | tags | `wcag2a` · `wcag2aa` · `wcag21aa` · `wcag22aa` — **the consumer's set, verbatim** |
32
+ | routes | every `/isolate/<id>` in `window.__STORY_MANIFEST__` + every `/showcase/<id>` |
33
+ | viewports | 1440×900 · 375×667 · **320×568** |
34
+
35
+ 320 is not decoration: it is WCAG 2.2 SC 1.4.10's reflow width, it is the width the consumer's
36
+ nightly runs, and it is the width gh#639 failed at while 390 passed.
37
+
38
+ Showcases are included on purpose. They are the only frames here shaped like a real screen — a whole
39
+ page, a landmark tree, a focus order — which is precisely the class the component frames cannot
40
+ reach and the consumer has been carrying alone.
41
+
42
+ ## What it found on its first run
43
+
44
+ 54 rows, **159 violation nodes**, on code that passed every other gate in this repository:
45
+
46
+ | nodes | rows | rule | what it means |
47
+ | ----: | ---: | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
48
+ | 62 | 17 | `color-contrast` | text under 4.5:1 (SC 1.4.3) |
49
+ | 28 | 16 | `target-size` | under 24×24 with no spacing exception (SC 2.5.8) — on `Carousel`, `Attachments`, `FilterBar` and `Toolbar`, at **every** width |
50
+ | 21 | 6 | `aria-prohibited-attr` | an `aria-*` on a role that does not allow it |
51
+ | 18 | 6 | `button-name` | **a button a screen reader announces as nothing** |
52
+ | 18 | 6 | `aria-valid-attr-value` | an `aria-*` pointing at an id that is not there |
53
+ | 12 | 3 | `aria-conditional-attr` | an `aria-*` that is invalid in the state it is in |
54
+
55
+ 48 of those nodes are on `/isolate/**` — single components, this package's own output — and 111 on
56
+ `/showcase/**`. None of the six rules exists in `scripts/visual-audit.mjs`. The `target-size` rows
57
+ are the sharpest: our own `target-size-min` rule was running on those same frames and reporting them
58
+ clean, because it measures a painted box.
59
+
60
+ ## The baseline is a debt ledger, not an allowlist
61
+
62
+ The package did not start compliant, and a gate that fails the whole build on its first day gets
63
+ deleted rather than obeyed. `frame-axe-baseline.json` records what was already failing when the gate
64
+ landed, keyed one row per `(route, viewport, rule)`:
65
+
66
+ ```
67
+ "/isolate/layout-topbar @320 target-size": { "count": 2, "help": "…" }
68
+ ```
69
+
70
+ The gate fails on a key that is **not** in the baseline, and on a baselined key whose `count`
71
+ **grows**. A key that stops firing is reported so it can be dropped.
72
+
73
+ - **Delete rows as you fix them.** `--update` rewrites the file; the diff is the review.
74
+ - **Never add a row by hand to turn a red build green.** That is the one move this file exists to
75
+ make visible.
76
+
77
+ ## What this gate does NOT do — stated, not hidden
78
+
79
+ The gate deleted in #492 did three things this one does not, and each is a real gap:
80
+
81
+ 1. **No overlay scope.** It scans the frame as rendered. A menu, dialog, listbox or popover that is
82
+ closed at rest is never measured, so `aria-hidden-focus` and friends stay outside its field of
83
+ view. The old gate opened one overlay per frame from a `data-axe-open` attribute; that attribute
84
+ was removed from the demos along with the gate and would have to come back.
85
+ 2. **No chrome/component split.** The old gate held the preview toolbar to zero violations and
86
+ allowlisted the component scope separately. `/isolate/**` renders the demo alone, so there is
87
+ little chrome to separate — but `/showcase/**` is scanned whole.
88
+ 3. **No per-rule severity.** Every WCAG-tagged rule is treated alike.
89
+
90
+ ## Determinism
91
+
92
+ Two consecutive sweeps of identical code first disagreed on five `color-contrast` rows: a fade-in
93
+ caught mid-flight renders text at partial opacity and axe scores whatever it finds. A gate that
94
+ disagrees with itself gets ignored, so each page is pinned before the scan —
95
+ `reducedMotion: "reduce"`, an injected stylesheet zeroing every animation and transition duration,
96
+ and `document.fonts.ready` (web fonts change glyph geometry, which changes which boxes overlap,
97
+ which changes what `color-contrast` resolves a background to). Two sweeps after that: identical.
98
+
99
+ ## Cost
100
+
101
+ | | |
102
+ | ---------------------------------- | ----------------------------------------------------------------------------------- |
103
+ | full sweep | **3m20s** locally · **12m01s** on the self-hosted runner — 215 routes × 3 viewports |
104
+ | sequential | ~55 min — the three viewport passes run concurrently, which is the whole difference |
105
+ | showcase only (`--scope=showcase`) | 30s |
106
+
107
+ `--shard=i/n` is in the script for the day the sweep outgrows the lane. Using it adds check-run
108
+ names, which costs nothing here because none of them is in `REQUIRED_CI_CHECK_RUNS`.
109
+
110
+ ## Where it runs
111
+
112
+ Two lanes, split on a **measurement taken on the runner, not on a laptop**:
113
+
114
+ | lane | what | when | measured |
115
+ | ----------------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------- | -------------------------------------- |
116
+ | `ci-browser.yml` · `Per-frame axe (showcases, WCAG 2.2 AA)` | `--scope=showcase` — 30 whole-page frames | every push to `main` | 30s locally |
117
+ | `ci-browser-full.yml` · `Per-frame axe (all frames, WCAG 2.2 AA)` | the full 215-route sweep | nightly, `workflow_dispatch`, or a `run-browser` label on a PR | **12m01s on the pool** (3m20s locally) |
118
+
119
+ The full sweep went into the merge lane first, on the local 3m20s. On the pool's runner the same
120
+ job took **12m01s** — three times the local wall clock and more than twice CONTRACT.md L4's
121
+ five-minute budget for that whole lane. So the merge lane keeps the showcases, which are the only
122
+ frames here shaped like the screens a consumer ships, and the wide matrix moved to the lane this
123
+ repository already reserves for wide matrices.
124
+
125
+ **Not** the PR lane: that file's own header lists axe among what it deliberately excludes, at a
126
+ measured 653–745s, and that decision is not reopened here.
127
+
128
+ The job is not in `REQUIRED_CI_CHECK_RUNS` (#492 removed it from the release proof map and this does
129
+ not put it back). It still protects a release through `assertCiProvenance`'s collateral rule — **any**
130
+ red check run on the SHA being published refuses the publish.
131
+
132
+ ## Related
133
+
134
+ - [FRAME-COVERAGE-STANDARD.md](./FRAME-COVERAGE-STANDARD.md) — which frames must exist at all.
135
+ - [DEVELOPMENT.md](./DEVELOPMENT.md) §5 — the `/isolate/<id>` · `/frame/<id>` addressing.
@@ -94,10 +94,18 @@ export default function Demo() {
94
94
  >
95
95
  {collapsed ? <PanelLeftOpen /> : <PanelLeftClose />}
96
96
  </Button>
97
- {/* Decorative mark — hidden below sm so the budget goes to the two real controls. */}
98
- <Avatar className="rounded-md">
99
- <AvatarFallback className="bg-primary text-primary-foreground font-bold">C</AvatarFallback>
100
- </Avatar>
97
+ {/* Decorative mark — hidden below sm so the budget goes to the two real controls. That was
98
+ only ever a COMMENT: nothing here hid anything, and at 320px the 32px mark was part of
99
+ why the switcher beside it was crushed to 0 visible px (gh#639). `hideBelow` is the
100
+ contract that makes the sentence true — a slot cannot shrink a control, so a bar that
101
+ does not fit has to drop one. */}
102
+ <Flex as="span" hideBelow="sm" gap="none">
103
+ <Avatar className="rounded-md">
104
+ <AvatarFallback className="bg-primary text-primary-foreground font-bold">
105
+ C
106
+ </AvatarFallback>
107
+ </Avatar>
108
+ </Flex>
101
109
  <DropdownMenu>
102
110
  <DropdownMenuTrigger asChild>
103
111
  {/* Button ships `shrink-0`, and only the LAST child of the start slot gets the built-in
@@ -150,10 +158,13 @@ export default function Demo() {
150
158
  // end · notifications + user menu, both consumer-composed.
151
159
  const end = (
152
160
  <>
153
- {}
154
- <Badge tone="warning" className="text-xs">
155
- ステージング
156
- </Badge>
161
+ {/* The environment marker is the end cluster's most optional item — 92px of a 320px bar, and
162
+ the single biggest reason the start cluster had nothing left. It goes first. */}
163
+ <Flex as="span" hideBelow="sm" gap="none">
164
+ <Badge tone="warning" className="text-xs">
165
+ ステージング
166
+ </Badge>
167
+ </Flex>
157
168
  <TopbarItem
158
169
  aria-label="通知"
159
170
  badge={unread ? 12 : undefined}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "24.0.0",
4
- "godxUiMcp": "24.0.0",
3
+ "version": "24.1.0",
4
+ "godxUiMcp": "24.1.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -322,6 +322,7 @@
322
322
  "check:example-imports": "node scripts/check-example-imports.mjs",
323
323
  "check:frame-coverage": "node scripts/frame-coverage.mjs",
324
324
  "check:frame-coverage-report": "node scripts/frame-coverage.mjs --check",
325
+ "check:frame-axe": "node scripts/check-frame-axe.mjs",
325
326
  "gen:frame-coverage-ledger": "node scripts/gen-frame-coverage-ledger.mjs",
326
327
  "check:frame-coverage-ledger": "node scripts/gen-frame-coverage-ledger.mjs --check && node scripts/check-frame-coverage-ledger.mjs",
327
328
  "check:screen-reader-evidence": "node scripts/check-screen-reader-evidence.mjs",
@@ -469,6 +470,7 @@
469
470
  "vaul": "^1.1.2"
470
471
  },
471
472
  "devDependencies": {
473
+ "@axe-core/playwright": "^4.13.0",
472
474
  "@eslint/js": "^10.0.1",
473
475
  "@hookform/resolvers": "^5.9.1",
474
476
  "@radix-ui/react-accordion": "^1.2.20",