@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.
- package/dist/components/navigation/dropdown-menu.js +19 -2
- package/dist/contracts/measurement.json +1 -1
- package/dist/styles/shell-layout.css +11 -2
- package/dist/tokens/components/shell.css +2 -0
- package/docs/DEVELOPMENT.md +20 -0
- package/docs/FRAME-A11Y-CI.md +135 -0
- package/docs/layout/topbar.tsx +19 -8
- package/package.json +4 -2
|
@@ -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": "
|
|
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
|
|
package/docs/DEVELOPMENT.md
CHANGED
|
@@ -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.
|
package/docs/layout/topbar.tsx
CHANGED
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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.
|
|
4
|
-
"godxUiMcp": "24.
|
|
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",
|