@godxjp/ui 25.3.0 → 26.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.
@@ -60,14 +60,16 @@ function applyPrimaryColor(root, color, foreground) {
60
60
  const hue = delta === 0 ? 0 : 60 * (max === r ? ((g - b) / delta + 6) % 6 : max === g ? (b - r) / delta + 2 : (r - g) / delta + 4);
61
61
  return `${hue} ${saturation * 100}% ${lightness * 100}%`;
62
62
  };
63
- const away = luminance(text) > 0.5 ? 0 : 1;
64
- const shade = (amount) => hsl(rgb.map((channel) => channel * (1 - amount) + away * amount));
63
+ const polarity = luminance(text) > 0.5 ? "darken" : "lighten";
65
64
  const palette = {
66
65
  "--primary": hsl(rgb),
67
66
  "--primary-foreground": hsl(text),
68
- "--primary-hover": shade(0.12),
69
- "--primary-active": shade(0.24),
70
- "--primary-border": hsl(rgb),
67
+ "--primary-hover": "initial",
68
+ "--primary-active": "initial",
69
+ "--primary-border": "initial",
70
+ "--control-outline": "initial",
71
+ "--primary-hover-channels": `var(--primary-hover-${polarity}-channels)`,
72
+ "--primary-active-channels": `var(--primary-active-${polarity}-channels)`,
71
73
  "--ring": hsl(rgb)
72
74
  };
73
75
  const previous = Object.keys(palette).map((key) => [
@@ -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": "25.3.0",
3
+ "version": "26.0.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,
@@ -100,8 +100,12 @@
100
100
  --color-link: hsl(var(--text-link));
101
101
  --color-brand: hsl(var(--text-brand));
102
102
 
103
- --color-primary-hover: hsl(var(--primary-hover));
104
- --color-primary-active: hsl(var(--primary-active));
103
+ --color-primary-hover: hsl(
104
+ var(--primary-hover, from hsl(var(--primary)) var(--primary-hover-channels))
105
+ );
106
+ --color-primary-active: hsl(
107
+ var(--primary-active, from hsl(var(--primary)) var(--primary-active-channels))
108
+ );
105
109
  --color-secondary-hover: hsl(var(--secondary-hover));
106
110
  --color-secondary-active: hsl(var(--secondary-active));
107
111
  --color-destructive-hover: hsl(var(--destructive-hover));
@@ -111,7 +111,7 @@
111
111
  }
112
112
 
113
113
  .ui-button--default:hover {
114
- background: hsl(var(--primary-hover));
114
+ background: hsl(var(--primary-hover, from hsl(var(--primary)) var(--primary-hover-channels)));
115
115
  }
116
116
 
117
117
  .ui-button--destructive {
@@ -114,8 +114,29 @@
114
114
  :root[data-focus-outline="on"] .ui-otp-slot[data-active="true"],
115
115
  :root[data-focus-outline="on"]
116
116
  .ui-otp-group[data-appearance="grouped"]:has(.ui-otp-slot[data-active="true"]) {
117
- --tw-ring-shadow: var(--focus-field-shadow);
118
- box-shadow: var(--focus-field-shadow);
117
+
118
+ --tw-ring-shadow: var(
119
+ --focus-field-shadow,
120
+ 0 0 0 var(--focus-ring-glow-width)
121
+ hsl(
122
+ var(
123
+ --focus-ring-glow-color,
124
+ var(--control-outline, from hsl(var(--primary)) var(--control-outline-channels))
125
+ ) /
126
+ var(--focus-ring-glow-alpha)
127
+ )
128
+ );
129
+ box-shadow: var(
130
+ --focus-field-shadow,
131
+ 0 0 0 var(--focus-ring-glow-width)
132
+ hsl(
133
+ var(
134
+ --focus-ring-glow-color,
135
+ var(--control-outline, from hsl(var(--primary)) var(--control-outline-channels))
136
+ ) /
137
+ var(--focus-ring-glow-alpha)
138
+ )
139
+ );
119
140
  }
120
141
 
121
142
  :root[data-focus-outline="on"]
@@ -285,7 +285,10 @@
285
285
 
286
286
  .app-main .ui-page-container {
287
287
  width: 100%;
288
- max-width: var(--app-shell-page-max-width);
288
+ }
289
+
290
+ .app-main :where(.ui-page-header, .ui-page-toolbar, .ui-page-body) {
291
+ max-inline-size: var(--app-shell-page-max-width);
289
292
  }
290
293
 
291
294
  .ui-auth-shell {
@@ -1648,7 +1651,7 @@
1648
1651
  transparent
1649
1652
  )
1650
1653
  );
1651
- color: var(--sidebar-item-active-foreground, hsl(var(--primary)));
1654
+ color: var(--sidebar-item-active-foreground, hsl(var(--primary-active, from hsl(var(--primary)) var(--primary-active-channels))));
1652
1655
  font-weight: var(--font-weight-medium);
1653
1656
  }
1654
1657
 
@@ -1765,7 +1768,7 @@
1765
1768
 
1766
1769
  .sb-nav-item--sub[data-active="true"] {
1767
1770
  background: transparent;
1768
- color: var(--sidebar-item-active-foreground, hsl(var(--primary)));
1771
+ color: var(--sidebar-item-active-foreground, hsl(var(--primary-active, from hsl(var(--primary)) var(--primary-active-channels))));
1769
1772
  font-weight: var(--font-weight-medium);
1770
1773
  }
1771
1774
 
@@ -231,7 +231,7 @@
231
231
  border: 0;
232
232
  background: none;
233
233
  padding: 0;
234
- color: var(--text-action-color);
234
+ color: var(--text-action-color, hsl(var(--primary)));
235
235
  font: inherit;
236
236
  cursor: pointer;
237
237
  vertical-align: baseline;
@@ -242,7 +242,10 @@
242
242
  }
243
243
  .ui-typography-action:hover,
244
244
  .ui-typography-action:focus-visible {
245
- color: var(--text-action-color-hover);
245
+ color: var(
246
+ --text-action-color-hover,
247
+ hsl(var(--primary-hover, from hsl(var(--primary)) var(--primary-hover-channels)))
248
+ );
246
249
  }
247
250
 
248
251
  .ui-typography-action[data-copied] {
@@ -63,8 +63,8 @@
63
63
 
64
64
  :root[data-focus-outline="on"] {
65
65
  --focus-outline: 1;
66
- --focus-field-shadow: 0 0 0 var(--focus-ring-glow-width)
67
- hsl(var(--focus-ring-glow-color) / var(--focus-ring-glow-alpha));
66
+
67
+ --focus-field-shadow: initial;
68
68
  }
69
69
 
70
70
  :root[data-focus-outline="off"] {
@@ -53,7 +53,8 @@
53
53
  --app-shell-nav-rail-height: var(--app-shell-nav-rail-width);
54
54
 
55
55
  --app-shell-nav-rail-inset: var(--space-3);
56
- --app-shell-page-max-width: 80rem;
56
+
57
+ --app-shell-page-max-width: none;
57
58
 
58
59
  --app-shell-main-background: initial;
59
60
  --app-shell-mobile-nav-width: 22.5rem;
@@ -12,8 +12,9 @@
12
12
 
13
13
  --text-action-gap: var(--space-1);
14
14
 
15
- --text-action-color: hsl(var(--primary));
16
- --text-action-color-hover: hsl(var(--primary-hover));
15
+ --text-action-color: initial;
16
+
17
+ --text-action-color-hover: initial;
17
18
 
18
19
  --text-action-size: 1em;
19
20
 
@@ -2,17 +2,27 @@
2
2
 
3
3
  :root {
4
4
 
5
- --primary-hover: 268.7 100% 41.6%;
5
+ --primary-hover: initial;
6
+ --primary-active: initial;
7
+ --primary-border: initial;
8
+ --control-outline: initial;
6
9
 
7
- --primary-active: 268.7 100% 34.5%;
10
+ --primary-hover-darken-channels: h s calc(l - 8.4);
11
+ --primary-active-darken-channels: h s calc(l - 15.5);
12
+ --primary-hover-lighten-channels: h s calc(l + 5.8);
13
+ --primary-active-lighten-channels: h s calc(l + 11.6 - 17.4 * clamp(0, (l - 83.4) * 1000, 1));
8
14
 
9
- --primary-border: 268.7 68% 66%;
10
- --destructive-hover: 359 50.6% 53.1%;
11
- --destructive-active: 355 70.6% 33.3%;
15
+ --primary-hover-channels: var(--primary-hover-darken-channels);
16
+ --primary-active-channels: var(--primary-active-darken-channels);
17
+
18
+ --primary-border-channels: h calc(s * 0.68) calc(l + 16);
12
19
 
13
- --control-outline: 268.7 100% 44.7%;
20
+ --control-outline-channels: h s calc(l - 5.3);
14
21
  --control-outline-alpha: 0.11;
15
22
 
23
+ --destructive-hover: 359 50.6% 53.1%;
24
+ --destructive-active: 355 70.6% 33.3%;
25
+
16
26
  --control-outline-error: 4.3 87.6% 34.7%;
17
27
  --control-outline-error-alpha: 0.09;
18
28
 
@@ -21,20 +31,36 @@
21
31
 
22
32
  .dark,
23
33
  :root[data-theme="dark"] {
34
+ --primary-hover-channels: var(--primary-hover-lighten-channels);
35
+ --primary-active-channels: var(--primary-active-lighten-channels);
24
36
 
25
- --primary-hover: 268.7 100% 92.7%;
37
+ --primary-border-channels: h calc(s * 0.467) calc(l * 0.27043);
26
38
 
27
- --primary-active: 268.7 100% 81.1%;
39
+ --control-outline-channels: h calc(s * 0.99) calc(l - 25.1);
40
+ --control-outline-alpha: 0.29;
28
41
 
29
- --primary-border: 268.7 46.7% 23.5%;
30
42
  --destructive-hover: 358.8 42.3% 53.1%;
31
43
  --destructive-active: 357.3 50.6% 34.1%;
32
44
 
33
- --control-outline: 268.7 99% 61.8%;
34
- --control-outline-alpha: 0.29;
35
-
36
45
  --control-outline-error: 351.5 98.3% 53.5%;
37
46
  --control-outline-error-alpha: 0.06;
38
47
 
39
48
  --ring: var(--primary);
40
49
  }
50
+
51
+ @supports not (color: hsl(from red h s l)) {
52
+ :root {
53
+ --primary-hover: 268.7 100% 41.6%;
54
+ --primary-active: 268.7 100% 34.5%;
55
+ --primary-border: 268.7 68% 66%;
56
+ --control-outline: 268.7 100% 44.7%;
57
+ }
58
+
59
+ .dark,
60
+ :root[data-theme="dark"] {
61
+ --primary-hover: 268.7 100% 92.7%;
62
+ --primary-active: 268.7 100% 81.1%;
63
+ --primary-border: 268.7 46.7% 23.5%;
64
+ --control-outline: 268.7 99% 61.8%;
65
+ }
66
+ }
@@ -125,11 +125,11 @@
125
125
  --focus-ring-opacity: 1;
126
126
 
127
127
  --focus-ring-glow-width: var(--control-outline-width);
128
- --focus-ring-glow-color: var(--control-outline);
128
+
129
+ --focus-ring-glow-color: initial;
129
130
  --focus-ring-glow-alpha: var(--control-outline-alpha);
130
131
 
131
- --focus-field-shadow: 0 0 0 var(--focus-ring-glow-width)
132
- hsl(var(--focus-ring-glow-color) / var(--focus-ring-glow-alpha));
132
+ --focus-field-shadow: initial;
133
133
 
134
134
  --focus-ring-offset: var(--focus-outline-offset);
135
135
 
@@ -230,7 +230,43 @@ Scope the overrides under a tenant attribute instead of `:root`. The colour util
230
230
  }
231
231
  ```
232
232
 
233
- Set `data-tenant` on the app root. Two CSS-inheritance caveats for the **scoped** case (a single `:root` brand theme is unaffected — there, overriding just `--radius` / `--shadow-color` cascades):
233
+ Set `data-tenant` on the app root.
234
+
235
+ #### What follows `--primary`, and what you still set (gh#678)
236
+
237
+ The interaction states are **derived from the `--primary` in scope, at the element that paints them** — on `<html>` or on any nested `[data-tenant]` / `[data-program]` element, in both themes. You do not declare them:
238
+
239
+ | Token | Follows `--primary`? | Default (evaluated at the painting element) |
240
+ | --- | --- | --- |
241
+ | `--primary-hover` | **yes** — Button hover, `bg-primary-hover`, Typography action hover | `hsl(from hsl(var(--primary)) var(--primary-hover-channels))` — light `h s calc(l - 8.4)`, dark `h s calc(l + 5.8)` |
242
+ | `--primary-active` | **yes** — `bg-primary-active`, the open sidebar row's label | light `h s calc(l - 15.5)`; dark one step further up, reflected to one step down past 83.4% L |
243
+ | `--control-outline` | **yes** — the focused field's halo (`--control-outline-alpha` stays per theme) | light `h s calc(l - 5.3)`, dark `h calc(s * 0.99) calc(l - 25.1)` |
244
+ | `--primary-border` | **yes** (painted by no package surface; kept for the measurement in DESIGN-AUTHORITY) | light `h calc(s * 0.68) calc(l + 16)`, dark `h calc(s * 0.467) calc(l * 0.27043)` |
245
+ | `--sidebar-item-active-foreground` | **yes** — defaults to the live `--primary-active` | — |
246
+ | `--primary-foreground` | **no — set it** | the label on a filled primary; you choose it for your seed |
247
+ | `--ring` | **only on the element that declares `--primary`** — set it in a nested scope | `var(--primary)` on `:root` / `.dark`. `--ring` is a public role read as `hsl(var(--ring))` in consumer CSS, so it cannot become a live default; a scope below `<html>` inherits the root's |
248
+ | `--destructive-*`, `--control-outline-error` | no — not brand | literals |
249
+
250
+ On the package seed these produce exactly the identity kit values the tier used to hard-code (`#6500d4` / `#5400b0` light, `#ecdaff` / `#cd9fff` dark). Engines without CSS relative colour (Chrome/Edge 111–118) get those literals and do **not** follow a consumer seed.
251
+
252
+ **Rules that come with it:**
253
+
254
+ - **Override a state only by setting its knob** (`--primary-hover: 221 90% 72%`). A set knob wins over the derived default, in its own scope and below.
255
+ - **Read a state through its fallback, not bare.** The four knobs are `initial` so that the default can resolve at the painting element (docs/TOKENS.md, the freeze rule). `hsl(var(--primary-hover))` on its own therefore paints nothing — use the utility (`bg-primary-hover`) or `hsl(var(--primary-hover, from hsl(var(--primary)) var(--primary-hover-channels)))`.
256
+ - **Label polarity.** The theme's pair steps AWAY from that theme's default label: `darken` under a light label, `lighten` under a dark one, which keeps a label that clears 4.5:1 at rest at 4.5:1 in hover and pressed for every seed (measured, `derived-seed-sweep.test.ts`). If your seed needs the OTHER polarity of label (a pale yellow with dark text in the light theme), point the pair at it too — `applyPrimaryColor()` does this automatically from the label it picks:
257
+
258
+ ```css
259
+ [data-tenant="sunny"] {
260
+ --primary: 48 100% 60%;
261
+ --primary-foreground: 48 9% 9%;
262
+ --primary-hover-channels: var(--primary-hover-lighten-channels);
263
+ --primary-active-channels: var(--primary-active-lighten-channels);
264
+ }
265
+ ```
266
+
267
+ - **The open sidebar row** is legible (≥ 4.5:1 on the 12% tint) for any seed that is itself legible as text on the page. A seed that is not (a pale seed on the light page, a very dark one on the dark page) needs `--sidebar-item-active-foreground` set.
268
+
269
+ Two more CSS-inheritance caveats for the **scoped** case (a single `:root` brand theme is unaffected — there, overriding just `--radius` / `--shadow-color` cascades):
234
270
 
235
271
  - **Radius & shadow-tint don't cascade from a scoped anchor.** `--radius-{xs…2xl}`, `--card-radius`, `--control-radius` and the `--shadow-{xs…2xl}` ramp are computed at their declaring element, so a scoped `--radius` / `--shadow-color` override won't reach them. For a scoped re-theme, re-declare the derived tokens you need (e.g. `--card-radius: var(--radius)`, or set `--card-shadow` to a literal value).
236
272
  ````
@@ -437,11 +437,15 @@ anywhere in `src/`, or leaves a trace in `dist/`. Deleting that gate re-opens th
437
437
  spacer. This library takes a different, two-form convention instead — widely used in enterprise
438
438
  component libraries, and surveyed across several before it was adopted here:
439
439
 
440
- - **Field** — the boundary RECOLOURS to the primary at its unchanged hairline width, plus
441
- `box-shadow: 0 0 0 var(--control-outline-width) var(--control-outline)`. A Select emits the same
440
+ - **Field** — the boundary RECOLOURS to the primary at its unchanged hairline width, plus a
441
+ `--control-outline-width` halo in the `--control-outline` colour. A Select emits the same
442
442
  declarations as an Input, which is why the two focus identically.
443
- - **Non-field** — an outline outside the box model:
444
- `outline: var(--focus-outline-weight) solid var(--primary-border); outline-offset: 1px`.
443
+ - **Non-field** — an outline outside the box model, `--focus-outline-weight` thick in the
444
+ `--primary-border` colour, `outline-offset: 1px`.
445
+
446
+ Since 26.0.0 (gh#678) `--control-outline` and `--primary-border` are `initial` knobs whose default
447
+ derives from `--primary` at the painting element, so a bare `var(--control-outline)` paints
448
+ **nothing**. Read them through their fallback — see `docs/CUSTOMER-THEMING.md` §Multi-tenant.
445
449
 
446
450
  **Why this shape wins.** The library previously shipped an opaque brand ring drawn immediately
447
451
  outside an untouched grey border — two outlines of different colours claiming the same edge — and a
@@ -24,7 +24,11 @@ import {
24
24
  * single page:
25
25
  *
26
26
  * ?topbarSpan=content|full ?navRail=start|end|top|bottom ?collapsed=1 ?sidebar=none
27
- * ?responsive=docked
27
+ * ?responsive=docked ?measure=narrow|medium ?footer=1
28
+ *
29
+ * `measure` and `footer` feed `scripts/check-app-shell-page-width.mjs` (gh#672): the page is fluid
30
+ * in the main column at every sidebar state, and a sticky footer band always spans `.app-main`,
31
+ * even when a service caps content with `--app-shell-page-max-width` or the page sets `measure`.
28
32
  *
29
33
  * With no query it is `topbarSpan="full"` without a rail — the state gh#474 found collapsing
30
34
  * `main` into a 170px implicit track below 900px, with the page actions laid out past the edge.
@@ -61,11 +65,13 @@ function readState() {
61
65
  hasSidebar: params.get("sidebar") !== "none",
62
66
  responsiveNavigation:
63
67
  params.get("responsive") === "docked" ? ("docked" as const) : ("drawer" as const),
68
+ measure: (["narrow", "medium"] as const).find((m) => m === params.get("measure")),
69
+ hasFooter: params.get("footer") === "1",
64
70
  };
65
71
  }
66
72
 
67
73
  export default function Demo() {
68
- const [{ topbarSpan, navRail, collapsed, hasSidebar, responsiveNavigation }] =
74
+ const [{ topbarSpan, navRail, collapsed, hasSidebar, responsiveNavigation, measure, hasFooter }] =
69
75
  useState(readState);
70
76
  const [activeId, setActiveId] = useState("dashboard");
71
77
  const strip = navRail === "top" || navRail === "bottom";
@@ -113,6 +119,18 @@ export default function Demo() {
113
119
  <PageContainer
114
120
  title="請求書"
115
121
  subtitle="株式会社アクメ · 2026年5月"
122
+ measure={measure}
123
+ footer={
124
+ hasFooter ? (
125
+ <Flex gap="sm" align="center" justify="end">
126
+ <Button size="sm" variant="outline">
127
+ 下書き保存
128
+ </Button>
129
+ <Button size="sm">コメントを送信する</Button>
130
+ </Flex>
131
+ ) : undefined
132
+ }
133
+ stickyFooter={hasFooter}
116
134
  extra={
117
135
  <Flex gap="sm" wrap>
118
136
  <Button size="sm" variant="outline">
@@ -555,12 +555,16 @@ function MonthGrid({ onPick }: { onPick: (d: DayCell) => void }) {
555
555
  opacity: cell.dim ? 0.5 : 1,
556
556
  }}
557
557
  >
558
- <button
559
- type="button"
558
+ <Button
559
+ variant="ghost"
560
+ shape="sharp"
561
+ fullWidth
562
+ wrap
563
+ align="start"
560
564
  onClick={() => onPick(cell)}
561
- className="hover:bg-accent focus-visible:ring-ring min-h-[96px] w-full text-start transition-colors focus:outline-none focus-visible:ring-2"
565
+ className="min-h-[96px]"
562
566
  >
563
- <Flex direction="col" gap="xs">
567
+ <Flex direction="col" gap="xs" fill>
564
568
  <Flex align="center" justify="between">
565
569
  <Text
566
570
  as="span"
@@ -604,7 +608,7 @@ function MonthGrid({ onPick }: { onPick: (d: DayCell) => void }) {
604
608
  ) : null}
605
609
  </Flex>
606
610
  </Flex>
607
- </button>
611
+ </Button>
608
612
  </TableCell>
609
613
  );
610
614
  })}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "25.3.0",
4
- "godxUiMcp": "25.3.0",
3
+ "version": "26.0.0",
4
+ "godxUiMcp": "26.0.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -334,8 +334,9 @@
334
334
  "check:choice-hit-target": "node scripts/check-choice-hit-target.mjs",
335
335
  "check:state-legible": "node scripts/check-state-legible.mjs",
336
336
  "check:app-shell-narrow-grid": "node scripts/check-app-shell-narrow-grid.mjs",
337
+ "check:app-shell-page-width": "node scripts/check-app-shell-page-width.mjs",
337
338
  "check:frame-contracts": "pnpm check:component-api-manifest && pnpm audit:component-cases && pnpm check:frame-coverage && pnpm check:frame-coverage-ledger && pnpm check:screen-reader-evidence && pnpm check:data-entry-owner-source && pnpm check:display-runtime-evidence",
338
- "check:frame-runtime": "pnpm check:data-entry-touch-aria && pnpm check:app-shell-narrow-grid && pnpm check:choice-hit-target",
339
+ "check:frame-runtime": "pnpm check:data-entry-touch-aria && pnpm check:app-shell-narrow-grid && pnpm check:app-shell-page-width && pnpm check:choice-hit-target",
339
340
  "verify": "pnpm typecheck && pnpm lint && pnpm format && pnpm build && pnpm preview:build && pnpm check:example-imports && pnpm check:core-isolation && pnpm check:no-consumer-coupling && pnpm check:prop-vocabulary && pnpm check:token-tiers && pnpm check:no-external-assets && pnpm check:token-scale-bypass && pnpm check:radix-surface && pnpm check:dist-tokens-resolve && pnpm check:no-hardcoded-geometry && pnpm check:no-hardcoded-css-values && pnpm check:disclosure-duplication && pnpm check:no-inline-magic-numbers && pnpm check:no-tailwind-class-assertions && pnpm check:control-sizing && pnpm check:rtl && pnpm check:typography && pnpm check:mcp-lockstep && pnpm check:mcp-sync && pnpm check:doc-prop-existence && pnpm check:mcp-catalog-coverage && pnpm check:mcp-orphans && pnpm check:mcp-pattern-imports && pnpm check:audit-sync && pnpm check:frame-coverage && pnpm test",
340
341
  "verify:static": "pnpm build && pnpm check:packed-public-contract && pnpm typecheck && pnpm typecheck:docs && pnpm lint && pnpm preview:build && pnpm check:example-imports && pnpm check:core-isolation && pnpm check:no-consumer-coupling && pnpm check:use-client && pnpm check:prop-vocabulary && pnpm check:token-tiers && pnpm check:token-scale-bypass && pnpm check:radix-surface && pnpm check:dist-tokens-resolve && pnpm check:no-hardcoded-geometry && pnpm check:no-hardcoded-css-values && pnpm check:disclosure-duplication && pnpm check:no-inline-magic-numbers && pnpm check:no-tailwind-class-assertions && pnpm check:control-sizing && pnpm check:rtl && pnpm check:typography && pnpm check:mcp-lockstep && pnpm check:mcp-sync && pnpm check:doc-prop-existence && pnpm check:mcp-catalog-coverage && pnpm check:mcp-orphans && pnpm check:mcp-pattern-imports && pnpm check:audit-sync && pnpm check:mcp-prop-sync && pnpm check:contrast && pnpm check:visual-audit && pnpm test",
341
342
  "verify:ci": "pnpm verify:ci:static && pnpm check:frame-contracts && pnpm test",
@@ -145,6 +145,19 @@ const EMOJI_FLAG = /\p{Regional_Indicator}/u;
145
145
  */
146
146
  const ATTRS = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|=>|[^>"'])*`;
147
147
 
148
+ /**
149
+ * A raw lowercase HTML opening tag, wherever its line ends (gh#673).
150
+ *
151
+ * The control rules were `/<button[\s>]/` run ONE LINE at a time. Prettier leaves `<button` alone on
152
+ * its line when it wraps an element, and the newline after the tag name is not part of that line, so
153
+ * every wrapped element was invisible: a consumer's audit reported 0 `no-raw-button` while ~166
154
+ * multi-line `<button` remained, and "0" read as "migration finished". Matched over the whole file
155
+ * (`spansElement`), the lookahead `[\s/>]` accepts that newline while `<buttonish`, `<Button` and
156
+ * `</button` stay unmatched. `[^\n]*` only carries the rest of the line into the snippet — and keeps
157
+ * one finding per line, which is what the per-line rule reported for a single-line element.
158
+ */
159
+ const rawTag = (tag) => new RegExp(`<${tag}(?=[\\s/>])[^\\n]*`, "g");
160
+
148
161
  /**
149
162
  * @type {{id:string, severity:'error'|'warn', test:RegExp, message:string, standard?:string,
150
163
  * exempt?:RegExp, classOnly?:boolean}[]}
@@ -185,7 +198,9 @@ const RULES = [
185
198
  severity: "error",
186
199
  // A hand-rolled flex/grid container. The primitives exist precisely so a row is <Flex>, a
187
200
  // stack is <Flex direction="col">, a grid is <ResponsiveGrid> — token gaps, RTL, density.
188
- test: /className=(?:"[^"]*|'[^']*|\{`[^`]*)(?<![\w-])(?:flex|inline-flex|grid|inline-grid)(?![\w-])/,
201
+ // Whole-file: a className template literal can hold `flex` several lines below `className=` (gh#673).
202
+ spansElement: true,
203
+ test: /className=(?:"[^"]*|'[^']*|\{`[^`]*)(?<![\w-])(?:flex|inline-flex|grid|inline-grid)(?![\w-])/g,
189
204
  message:
190
205
  'No hand-rolled flex/grid — use <Flex> (row), <Flex direction="col"> (stack) or <ResponsiveGrid> (docs/CONSUMER-RULES.md §3).',
191
206
  },
@@ -203,17 +218,23 @@ const RULES = [
203
218
  // rounded-full` dot was clean, `prettier --write` reordered it to `rounded-full bg-current`,
204
219
  // and the warning appeared with no source change. A rule a formatter can flip is a rule
205
220
  // nobody can trust.
206
- test: (line) => {
207
- const m = line.match(/className=(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`)/);
208
- if (!m) return false;
209
- const cls = m[1] ?? m[2] ?? m[3] ?? "";
210
- // A MARKER is not a surface. `size-1` is 4px — a dot, a pip, a status bead. The primitives
211
- // this rule points at (Card, Badge, Avatar, ListRow, Descriptions, EmptyState) all start at
212
- // control height, so none of them can express one, and flagging it sends the reader looking
213
- // for a component that does not exist. The cut-off is `size-2` / 8px: below that there is no
214
- // room for the padding and type that make something a surface.
215
- if (/\b(?:size|[wh])-(?:0\.5|1|1\.5|2)\b/.test(cls)) return false;
216
- return /\brounded(?:-(?:full|sm|md|lg|xl|2xl))?\b/.test(cls) && /\b(?:border|bg-)/.test(cls);
221
+ //
222
+ // Whole-file, one verdict per className: a template literal spread over lines puts `rounded`
223
+ // and `border` on lines of their own, where a per-line read saw neither (gh#673).
224
+ spansElement: true,
225
+ matches: function* (source) {
226
+ for (const m of source.matchAll(/className=(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`)/g)) {
227
+ const cls = m[1] ?? m[2] ?? m[3] ?? "";
228
+ // A MARKER is not a surface. `size-1` is 4px — a dot, a pip, a status bead. The primitives
229
+ // this rule points at (Card, Badge, Avatar, ListRow, Descriptions, EmptyState) all start at
230
+ // control height, so none of them can express one, and flagging it sends the reader looking
231
+ // for a component that does not exist. The cut-off is `size-2` / 8px: below that there is no
232
+ // room for the padding and type that make something a surface.
233
+ if (/\b(?:size|[wh])-(?:0\.5|1|1\.5|2)\b/.test(cls)) continue;
234
+ if (/\brounded(?:-(?:full|sm|md|lg|xl|2xl))?\b/.test(cls) && /\b(?:border|bg-)/.test(cls)) {
235
+ yield m;
236
+ }
237
+ }
217
238
  },
218
239
  message:
219
240
  "Hand-rolled surface (rounded + border/bg) — use Card, Badge, Avatar, ListRow, Descriptions or EmptyState so height, padding and radius come from the tokens (docs/CONSUMER-RULES.md §4). A read-only sample of a colour a USER chose is Swatch, which takes that value as a prop.",
@@ -360,14 +381,16 @@ const RULES = [
360
381
  replacement: "Select",
361
382
  scope: "consumer-control",
362
383
  severity: "error",
363
- test: /<select[\s>]/,
384
+ spansElement: true,
385
+ test: rawTag("select"),
364
386
  message: "Use <Select> from @godxjp/ui, not a raw <select> (rules §3).",
365
387
  },
366
388
  {
367
389
  id: "no-raw-table",
368
390
  replacement: "DataTable / Table",
369
391
  severity: "error",
370
- test: /<table[\s>]/,
392
+ spansElement: true,
393
+ test: rawTag("table"),
371
394
  message: "Use the <Table> family from @godxjp/ui, not a raw <table> (rules §3).",
372
395
  },
373
396
  {
@@ -375,7 +398,8 @@ const RULES = [
375
398
  replacement: "Textarea",
376
399
  scope: "consumer-control",
377
400
  severity: "warn",
378
- test: /<textarea[\s>]/,
401
+ spansElement: true,
402
+ test: rawTag("textarea"),
379
403
  message: "Use <Textarea> from @godxjp/ui, not a raw <textarea> (rules §3).",
380
404
  },
381
405
  {
@@ -392,7 +416,8 @@ const RULES = [
392
416
  replacement: "Button",
393
417
  scope: "consumer-control",
394
418
  severity: "error",
395
- test: /<button[\s>]/,
419
+ spansElement: true,
420
+ test: rawTag("button"),
396
421
  message: "Use <Button> from @godxjp/ui, not a raw <button> (rules §3).",
397
422
  },
398
423
  {
@@ -423,7 +448,9 @@ const RULES = [
423
448
  {
424
449
  id: "manual-field-helper",
425
450
  severity: "warn",
426
- test: /<p[^>]*className="text-xs text-muted-foreground"/,
451
+ // Whole-file: prettier wraps `<p` and its className onto separate lines (gh#673).
452
+ spansElement: true,
453
+ test: new RegExp(`<p${ATTRS}className="text-xs text-muted-foreground"`, "g"),
427
454
  message:
428
455
  "Field helper text should use <FormField helper=…>, not a hand-rolled <p> (rules §1). OK only for checkbox/radio groups.",
429
456
  },
@@ -541,7 +568,9 @@ const RULES = [
541
568
  * — one of ten standing warnings here. Flagging it would push every consumer doing the right
542
569
  * thing toward either a suppression comment or a worse glyph.
543
570
  */
544
- test: />\s*[✕✖×╳]\s*</,
571
+ // Whole-file: prettier puts a wrapped element's text on its own line, `>` and `<` elsewhere (gh#673).
572
+ spansElement: true,
573
+ test: />\s*[✕✖×╳]\s*</g,
545
574
  exempt: /<VisuallyHidden\b|sr-only/,
546
575
  standard: "WAI-ARIA 1.2 (dialog) · WCAG 2.2 SC 4.1.2 · @godxjp/ui Alert/Dialog anatomy",
547
576
  message:
@@ -1190,7 +1219,11 @@ for (const dir of SCAN_DIRS) {
1190
1219
  continue;
1191
1220
  const lineNo = scanContent.slice(0, match.index).split("\n").length;
1192
1221
  if (suppressed(rule.id, lineNo - 1)) continue;
1193
- if (isExempt(rule, origLines[lineNo - 1], origLines[lineNo - 2])) continue;
1222
+ // "The line after" is the one after the match ENDS: a wrapped glyph's name follows its `</span>`.
1223
+ const endLineNo = lineNo + (match[0].match(/\n/g)?.length ?? 0);
1224
+ if (isExempt(rule, origLines[lineNo - 1], origLines[lineNo - 2], origLines[endLineNo])) {
1225
+ continue;
1226
+ }
1194
1227
  findings.push({
1195
1228
  file: rel,
1196
1229
  line: lineNo,
@@ -1198,6 +1231,7 @@ for (const dir of SCAN_DIRS) {
1198
1231
  severity: rule.severity,
1199
1232
  standard: rule.standard,
1200
1233
  message: rule.message,
1234
+ replacement: rule.replacement,
1201
1235
  snippet: match[0].replace(/\s+/g, " ").slice(0, 120),
1202
1236
  });
1203
1237
  }