@godxjp/ui 30.7.1 → 30.9.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.
@@ -0,0 +1,2 @@
1
+
2
+ @import "react-day-picker/style.css" layer(vendor);
@@ -0,0 +1,2 @@
1
+
2
+ @import "sonner/dist/styles.css" layer(vendor);
@@ -109,6 +109,14 @@ selector, its value, and whether it is a freeze; then every read and whether tha
109
109
  call-site fallback. If two components move together, run it on the token they share and the shared
110
110
  declaration is on the screen.
111
111
 
112
+ **Before you rely on a token, read its `published:` line.** `yes` is the contract; `NO` is an
113
+ internal variable that can change or vanish in any release — `--text-xs` looks like the obvious
114
+ name for the small size, but it is Tailwind's inlined `0.75rem` (12px), while the published
115
+ `--font-size-xs` resolves to 12.4699px. A consumer who patched with the first shipped a 0.47px shift
116
+ no gate caught (gh#988). For an `initial` knob the tool also prints `read it as: var(--x, …)` — the
117
+ fallback the package itself uses at its read sites. Copy that line; a bare `var(--x)` on an
118
+ `initial` knob is unset.
119
+
112
120
  **It does not compute a winner, and says so.** The first version ranked selectors into four
113
121
  "cascade" buckets by regex and printed them strongest-last. Codex found that `@theme inline` and
114
122
  `[dir="rtl"] .ui-actions[data-fade-in-inline]` both scored top precedence on the substring
@@ -356,39 +356,43 @@ export default function Demo() {
356
356
  <CardContent>
357
357
  <Form layout="horizontal" labelWidth="9rem">
358
358
  <FormField id="cmp-tel" label="電話番号" helper="市外局番から">
359
- <Flex direction="row" gap="sm" align="center">
360
- <Flex width="5rem" shrink={false}>
361
- <Input
362
- id="cmp-tel-area"
363
- aria-label="市外局番"
364
- inputMode="numeric"
365
- maxLength={4}
366
- defaultValue="03"
367
- />
368
- </Flex>
369
- <Text aria-hidden tone="muted">
370
- -
371
- </Text>
372
- <Flex width="5rem" shrink={false}>
373
- <Input
374
- id="cmp-tel-city"
375
- aria-label="市内局番"
376
- inputMode="numeric"
377
- maxLength={4}
378
- defaultValue="6273"
379
- />
380
- </Flex>
381
- <Text aria-hidden tone="muted">
382
- -
383
- </Text>
384
- <Flex width="5rem" shrink={false}>
385
- <Input
386
- id="cmp-tel-line"
387
- aria-label="加入者番号"
388
- inputMode="numeric"
389
- maxLength={4}
390
- defaultValue="0001"
391
- />
359
+ {/* The three parts never split across lines — "03 -" alone on a row reads as a
360
+ whole number. The extension is a separate value, so it is what wraps on a phone. */}
361
+ <Flex direction="row" gap="sm" align="center" wrap>
362
+ <Flex direction="row" gap="sm" align="center" shrink={false}>
363
+ <Flex width="4rem" shrink={false}>
364
+ <Input
365
+ id="cmp-tel-area"
366
+ aria-label="市外局番"
367
+ inputMode="numeric"
368
+ maxLength={4}
369
+ defaultValue="03"
370
+ />
371
+ </Flex>
372
+ <Text aria-hidden tone="muted">
373
+ -
374
+ </Text>
375
+ <Flex width="4rem" shrink={false}>
376
+ <Input
377
+ id="cmp-tel-city"
378
+ aria-label="市内局番"
379
+ inputMode="numeric"
380
+ maxLength={4}
381
+ defaultValue="6273"
382
+ />
383
+ </Flex>
384
+ <Text aria-hidden tone="muted">
385
+ -
386
+ </Text>
387
+ <Flex width="4rem" shrink={false}>
388
+ <Input
389
+ id="cmp-tel-line"
390
+ aria-label="加入者番号"
391
+ inputMode="numeric"
392
+ maxLength={4}
393
+ defaultValue="0001"
394
+ />
395
+ </Flex>
392
396
  </Flex>
393
397
  <Flex width="6rem" shrink={false}>
394
398
  <NumberInput
@@ -499,19 +503,19 @@ export default function Demo() {
499
503
  </FormField>
500
504
  <FormField id="cmp-ok-tel" label="電話番号" validateStatus="success" hasFeedback>
501
505
  <Flex direction="row" gap="sm" align="center">
502
- <Flex width="5rem" shrink={false}>
506
+ <Flex width="4rem" shrink={false}>
503
507
  <Input id="cmp-ok-tel-area" aria-label="市外局番" defaultValue="03" />
504
508
  </Flex>
505
509
  <Text aria-hidden tone="muted">
506
510
  -
507
511
  </Text>
508
- <Flex width="5rem" shrink={false}>
512
+ <Flex width="4rem" shrink={false}>
509
513
  <Input id="cmp-ok-tel-city" aria-label="市内局番" defaultValue="6273" />
510
514
  </Flex>
511
515
  <Text aria-hidden tone="muted">
512
516
  -
513
517
  </Text>
514
- <Flex width="5rem" shrink={false}>
518
+ <Flex width="4rem" shrink={false}>
515
519
  <Input id="cmp-ok-tel-line" aria-label="加入者番号" defaultValue="0001" />
516
520
  </Flex>
517
521
  </Flex>
@@ -1111,7 +1115,7 @@ export default function Demo() {
1111
1115
  </CardHeader>
1112
1116
  <CardContent>
1113
1117
  <Flex direction="col" gap="lg">
1114
- <Form layout="horizontal" labelWidth="14rem" labelAlign="end" collapseBelow={false}>
1118
+ <Form layout="horizontal" labelWidth="14rem" labelAlign="end">
1115
1119
  <FormField id="len-e-name" label="氏名" required>
1116
1120
  <Input id="len-e-name" defaultValue="山田 太郎" />
1117
1121
  </FormField>
@@ -1127,7 +1131,7 @@ export default function Demo() {
1127
1131
  <Input id="len-e-note" defaultValue="ゴドー商事" />
1128
1132
  </FormField>
1129
1133
  </Form>
1130
- <Form layout="horizontal" labelWidth="14rem" labelAlign="start" collapseBelow={false}>
1134
+ <Form layout="horizontal" labelWidth="14rem" labelAlign="start">
1131
1135
  <FormField id="len-s-name" label="氏名" required>
1132
1136
  <Input id="len-s-name" defaultValue="山田 太郎" />
1133
1137
  </FormField>
@@ -395,8 +395,8 @@ export default function Demo() {
395
395
  </CardDescription>
396
396
  </CardHeader>
397
397
  <CardContent>
398
- <Flex direction={{ base: "col", md: "row" }} gap="xl" align="start">
399
- <Flex direction="col" gap="sm" grow>
398
+ <Flex direction={{ base: "col", md: "row" }} gap="xl">
399
+ <Flex direction="col" gap="sm" fill>
400
400
  <Text size="sm" weight="medium">
401
401
  レール幅(compact · 18.75rem)
402
402
  </Text>
@@ -182,8 +182,10 @@ export default function Demo() {
182
182
  </CardDescription>
183
183
  </CardHeader>
184
184
  <CardContent>
185
- <Flex gap="lg" align="start">
186
- <ScrollArea className="h-96 w-64 shrink-0" label="Contents">
185
+ {/* Side by side from md; on a phone the rail stacks above the text it indexes. A 256px
186
+ rail beside a 244px card left the chapters a column one glyph wide (gh#965). */}
187
+ <Flex direction={{ base: "col", md: "row" }} gap="lg" align="start">
188
+ <ScrollArea className="h-48 w-full shrink-0 md:h-96 md:w-64" label="Contents">
187
189
  <Anchor
188
190
  affix={false}
189
191
  showInkInFixed
@@ -192,7 +194,11 @@ export default function Demo() {
192
194
  getContainer={() => chaptersRef.current ?? window}
193
195
  />
194
196
  </ScrollArea>
195
- <ScrollArea viewportRef={chaptersRef} className="h-96 flex-1" label="Agreement">
197
+ <ScrollArea
198
+ viewportRef={chaptersRef}
199
+ className="h-96 w-full md:w-auto md:flex-1"
200
+ label="Agreement"
201
+ >
196
202
  <Flex direction="col" gap="md">
197
203
  {CHAPTERS.map((chapter) => (
198
204
  <Chapter
@@ -251,24 +257,28 @@ export default function Demo() {
251
257
  </CardHeader>
252
258
  <CardContent>
253
259
  <Flex gap="lg" align="start">
254
- <Anchor
255
- offsetBlockStart={16}
256
- targetOffsetBlockStart={24}
257
- label="On this page"
258
- items={[
259
- { key: "page-a", href: "#page-alpha", title: "Alpha · 第一章" },
260
- {
261
- key: "page-b",
262
- href: "#page-beta",
263
- title: "Beta · 第二章",
264
- children: [
265
- { key: "page-b1", href: "#page-beta-one", title: "Beta, part one" },
266
- { key: "page-b2", href: "#page-beta-two", title: "Beta, part two" },
267
- ],
268
- },
269
- { key: "page-c", href: "#page-gamma", title: "Gamma · 第三章" },
270
- ]}
271
- />
260
+ {/* A pinned side rail has no room on a phone: at 320px it was squeezed to 58px and
261
+ broke "Gamma" mid-word. Documentation sites drop the in-page rail below md. */}
262
+ <Flex hideBelow="md">
263
+ <Anchor
264
+ offsetBlockStart={16}
265
+ targetOffsetBlockStart={24}
266
+ label="On this page"
267
+ items={[
268
+ { key: "page-a", href: "#page-alpha", title: "Alpha · 第一章" },
269
+ {
270
+ key: "page-b",
271
+ href: "#page-beta",
272
+ title: "Beta · 第二章",
273
+ children: [
274
+ { key: "page-b1", href: "#page-beta-one", title: "Beta, part one" },
275
+ { key: "page-b2", href: "#page-beta-two", title: "Beta, part two" },
276
+ ],
277
+ },
278
+ { key: "page-c", href: "#page-gamma", title: "Gamma · 第三章" },
279
+ ]}
280
+ />
281
+ </Flex>
272
282
  <Flex direction="col" gap="md" className="flex-1">
273
283
  <Chapter id="page-alpha" title="Alpha · 第一章" lines={6} />
274
284
  <Chapter id="page-beta" title="Beta · 第二章" lines={2} />
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "30.7.1",
4
- "godxUiMcp": "30.7.1",
3
+ "version": "30.9.0",
4
+ "godxUiMcp": "30.9.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -26,6 +26,7 @@
26
26
  "agent",
27
27
  "dist",
28
28
  "scripts/ui-audit.mjs",
29
+ "scripts/prune-css.mjs",
29
30
  "README.md",
30
31
  "scripts/visual-audit.mjs",
31
32
  "scripts/visual-audit-rules.mjs",
@@ -313,6 +314,7 @@
313
314
  "build:jis-level1-fonts": "node scripts/build-jis-level1-fonts.mjs",
314
315
  "capture:voiceover": "node scripts/capture-voiceover-evidence.mjs",
315
316
  "check:agent-catalog": "node scripts/gen-agent-catalog.mjs --check",
317
+ "check:style-layers": "node scripts/gen-style-layers.mjs --check",
316
318
  "check:app-shell-narrow-grid": "node scripts/check-app-shell-narrow-grid.mjs",
317
319
  "check:app-shell-page-width": "node scripts/check-app-shell-page-width.mjs",
318
320
  "check:audit-sync": "node scripts/check-audit-sync.mjs",
@@ -388,6 +390,7 @@
388
390
  "gen:email-tokens": "node scripts/gen-email-tokens.mjs",
389
391
  "gen:frame-coverage-ledger": "node scripts/gen-frame-coverage-ledger.mjs",
390
392
  "gen:registry": "node scripts/gen-registry.mjs",
393
+ "gen:style-layers": "node scripts/gen-style-layers.mjs",
391
394
  "init-agent": "node scripts/init-agent-kit.mjs",
392
395
  "lint": "eslint . --cache --cache-strategy content",
393
396
  "lint:fix": "eslint . --fix",
@@ -415,7 +418,7 @@
415
418
  "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:token-width-wins && 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",
416
419
  "verify:browser": "pnpm check:contrast && pnpm check:text-ink-clip && pnpm check:frame-overflow && pnpm check:font-fallback-metrics && pnpm check:visual-audit",
417
420
  "verify:ci": "pnpm verify:ci:static && pnpm check:frame-contracts && pnpm test",
418
- "verify:ci:static:gates": "pnpm build && pnpm check:packed-public-contract && pnpm typecheck && pnpm typecheck:docs && pnpm lint && pnpm preview:build && pnpm check:registry && 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-width-wins && pnpm check:no-external-assets && pnpm check:token-scale-bypass && pnpm check:no-antd-runtime && 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:measurement-contract && pnpm check:mcp-token-sync && pnpm check:email-token-sync && pnpm check:mcp-sync && pnpm check:doc-prop-existence && pnpm check:mcp-catalog-coverage && pnpm check:mcp-orphans && pnpm check:mcp-catalog-completeness && pnpm check:mcp-pattern-imports && pnpm check:audit-sync && pnpm run audit && pnpm check:frame-coverage-report && pnpm check:gate-coverage && pnpm check:mcp-prop-sync && pnpm check:catalog-contradictions && pnpm check:catalog-snippets && pnpm check:absorbed-names && pnpm check:agent-catalog",
421
+ "verify:ci:static:gates": "pnpm build && pnpm check:packed-public-contract && pnpm typecheck && pnpm typecheck:docs && pnpm lint && pnpm preview:build && pnpm check:registry && 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-width-wins && pnpm check:no-external-assets && pnpm check:token-scale-bypass && pnpm check:no-antd-runtime && 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:measurement-contract && pnpm check:mcp-token-sync && pnpm check:email-token-sync && pnpm check:mcp-sync && pnpm check:doc-prop-existence && pnpm check:mcp-catalog-coverage && pnpm check:mcp-orphans && pnpm check:mcp-catalog-completeness && pnpm check:mcp-pattern-imports && pnpm check:audit-sync && pnpm run audit && pnpm check:frame-coverage-report && pnpm check:gate-coverage && pnpm check:mcp-prop-sync && pnpm check:catalog-contradictions && pnpm check:catalog-snippets && pnpm check:absorbed-names && pnpm check:agent-catalog && pnpm check:style-layers",
419
422
  "verify:ci:static": "node scripts/run-gate-list.mjs verify:ci:static:gates",
420
423
  "verify:publish-tree": "pnpm build && pnpm check:packed-public-contract && pnpm check:use-client && pnpm check:dist-tokens-resolve",
421
424
  "verify:release": "pnpm verify:static && pnpm check:frame-contracts && pnpm check:frame-coverage",
package/scripts/cli.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  * sync-rules refresh package-owned agent rules (same path as postinstall)
6
6
  * audit static UI audit (regex over source)
7
7
  * visual-audit runtime audit (Playwright + axe-core) against a running app
8
+ * prune-css emit a stylesheet with only the CSS layers the app uses (gh#971)
8
9
  */
9
10
  import { spawnSync } from "node:child_process";
10
11
  import { dirname, join } from "node:path";
@@ -16,6 +17,7 @@ const MAP = {
16
17
  "sync-rules": "postinstall.mjs",
17
18
  audit: "ui-audit.mjs",
18
19
  "visual-audit": "visual-audit.mjs",
20
+ "prune-css": "prune-css.mjs",
19
21
  };
20
22
 
21
23
  // `<command> --help` is answered HERE: the scripts take free positional arguments, so a `--help`
@@ -52,6 +54,18 @@ const HELP = {
52
54
  --quiet print errors only (warnings hidden)
53
55
  --rules print the rule catalog as JSON and exit
54
56
  Pre-commit: npx godxjp-ui audit resources/js || exit 1`,
57
+ "prune-css": `godxjp-ui prune-css <src dir/glob …> [--out <file>] [--fonts]
58
+
59
+ Emit a stylesheet with only the component CSS layers your app uses (gh#971). Scans the given
60
+ sources for @godxjp/ui imports, resolves the layer dependency closure from the graph the
61
+ package ships (dist/styles/layers.json), and writes a css file that imports the foundation
62
+ plus only the needed *-layout.css layers, in the exact order styles/index.css loads them.
63
+ <src dir/glob …> your app's source (directories are walked)
64
+ --out <file> output path (default: godx-ui.css)
65
+ --fonts include the bundled @font-face declarations (default mirrors styles/core)
66
+ Import the emitted file INSTEAD of "@godxjp/ui/styles". Re-run when your component usage
67
+ changes and after every upgrade; it refuses on a package/manifest version mismatch.
68
+ Hand cherry-picking *-layout.css stays forbidden — this tool is the only thing allowed to slice.`,
55
69
  "visual-audit": `godxjp-ui visual-audit [--format json] [--strict] <baseUrl> [route …]
56
70
 
57
71
  Runtime audit (Playwright + axe-core) against an app you are ALREADY running locally.
@@ -70,6 +84,7 @@ commands:
70
84
  sync-rules refresh package-owned agent rules (postinstall, by hand)
71
85
  audit static UI audit over source
72
86
  visual-audit runtime audit (Playwright + axe-core) against a running app
87
+ prune-css emit a stylesheet with only the CSS layers your app uses
73
88
 
74
89
  godxjp-ui <command> --help details and flags for one command`;
75
90
 
@@ -39,9 +39,15 @@
39
39
  * node scripts/explain-token.mjs --json <name> machine-readable, for a gate
40
40
  */
41
41
  import { readFileSync, readdirSync, statSync } from "node:fs";
42
- import { join, relative } from "node:path";
42
+ import { dirname, join, relative } from "node:path";
43
+ import { fileURLToPath } from "node:url";
43
44
 
44
- const ROOT = process.cwd();
45
+ /* The PACKAGE root, from this file's own location — never `process.cwd()` (gh#980). The documented
46
+ * call is `node node_modules/@godxjp/ui/scripts/explain-token.mjs` from the consumer's directory, and
47
+ * with cwd as the root every read below looked in the consumer's tree: no catalog, no CSS, and a
48
+ * confident "no token matches" for every token that exists. In this checkout the two roots coincide,
49
+ * which is why the defect was invisible here. */
50
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
45
51
 
46
52
  /** Source of truth for what a CONSUMER can set: the published catalog, not the stylesheets. */
47
53
  function publishedTokens() {
@@ -139,11 +145,37 @@ function parse(file) {
139
145
  .slice(m.index, m.index + 400)
140
146
  .replace(/\s+/g, " ")
141
147
  .includes(`${m[1]},`),
148
+ fallback: fallbackAt(blank, m.index),
142
149
  });
143
150
  }
144
151
  return { decls, reads };
145
152
  }
146
153
 
154
+ /**
155
+ * The fallback text of the `var(` that opens at `at`, or null. Balanced on parentheses, because the
156
+ * fallbacks this package writes are formulas — `calc(var(--font-size-base) / var(--font-size-ratio))`
157
+ * — and stopping at the first `)` would print half of one.
158
+ */
159
+ function fallbackAt(blank, at) {
160
+ let depth = 0;
161
+ let comma = -1;
162
+ for (let i = at; i < blank.length; i += 1) {
163
+ const ch = blank[i];
164
+ if (ch === "(") depth += 1;
165
+ else if (ch === ")") {
166
+ depth -= 1;
167
+ if (depth === 0)
168
+ return comma < 0
169
+ ? null
170
+ : blank
171
+ .slice(comma + 1, i)
172
+ .trim()
173
+ .replace(/\s+/g, " ");
174
+ } else if (ch === "," && depth === 1 && comma < 0) comma = i;
175
+ }
176
+ return null;
177
+ }
178
+
147
179
  /** The selector chain enclosing a byte offset, outermost first. */
148
180
  function contextAt(blank, at) {
149
181
  const chain = [];
@@ -282,6 +314,27 @@ function trace(name, { decls, reads }, published, scopedNames) {
282
314
  );
283
315
  }
284
316
  if (myReads.length > 12) console.log(` … and ${myReads.length - 12} more`);
317
+
318
+ /* THE PATCH SHAPE, printed rather than left to be inferred (gh#988). An `initial` knob read bare
319
+ * is unset, so a consumer migrating a rule has to copy the package's own fallback — and without
320
+ * this line they went looking for a shorter token instead, picked `--text-xs` (unpublished,
321
+ * Tailwind-inlined to 12px) over `--font-size-xs` (12.4699px), and shipped a 0.47px shift no
322
+ * gate caught. The most common fallback wins; the count says how settled it is. */
323
+ if (mine.some((d) => d.value === "initial")) {
324
+ const counts = new Map();
325
+ for (const r of myReads)
326
+ if (r.fallback) counts.set(r.fallback, (counts.get(r.fallback) ?? 0) + 1);
327
+ const [best, n] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? [];
328
+ if (best) {
329
+ console.log(` read it as: var(${name}, ${best})`);
330
+ console.log(
331
+ ` ${n} of ${myReads.length} read(s) use this fallback — the knob is \`initial\`, so a bare read is unset.`,
332
+ );
333
+ }
334
+ }
335
+ if (!tier && myReads.length === 0) {
336
+ console.log(" → not part of the contract: read a published token instead.");
337
+ }
285
338
  return { name, tier, decls: mine, reads: myReads };
286
339
  }
287
340
 
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * prune-css — emit a consumer stylesheet with only the component CSS layers the app uses
4
+ * (gh#971). Runs in the CONSUMER repo:
5
+ *
6
+ * node node_modules/@godxjp/ui/scripts/prune-css.mjs resources/js [more dirs/globs …]
7
+ * npx @godxjp/ui prune-css resources/js --out resources/css/godx-ui.css
8
+ *
9
+ * It scans the given sources for imports from `@godxjp/ui` (any subpath), maps the imported
10
+ * components to CSS layer files through the dependency graph the package ships in
11
+ * dist/styles/layers.json, takes the closure, and writes a css file that @imports the
12
+ * foundation ALWAYS (base — Tailwind entry, tokens, theme, density, focus ring) plus only the
13
+ * needed `*-layout.css` layers, in exactly the order styles/index.css loads them — layer order
14
+ * is load-bearing there (icon-layout.css is last on purpose, and the vendor sheets sit in
15
+ * `layer(vendor)` before base).
16
+ *
17
+ * This is the sanctioned alternative to the forbidden act: README still says "do not
18
+ * cherry-pick *-layout.css files", and that rule is WHY this tool exists — the layer
19
+ * dependency graph is owned and shipped by the package, so the slice is computed, never
20
+ * hand-guessed. A missing layer fails silently (naked menus, unsized rows); a computed one
21
+ * cannot go missing without the manifest's own CI guard going red first.
22
+ *
23
+ * Fonts mirror `@godxjp/ui/styles/core`: NO bundled `@font-face` by default (that is where
24
+ * 77% of the all-in entry's weight lives — gh#971). Pass `--fonts` for the bundled faces,
25
+ * the same thing `@godxjp/ui/styles` adds over `core`.
26
+ *
27
+ * Re-run it whenever the set of components the app uses changes, and after every
28
+ * @godxjp/ui upgrade. The emitted file refuses to be hand-edited by carrying its own
29
+ * provenance header, and this script refuses to run against a manifest whose version does
30
+ * not match the installed package — a half-upgraded node_modules must fail loudly, not
31
+ * emit a slice computed from another release's graph.
32
+ */
33
+ import { existsSync, globSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
34
+ import { dirname, join, relative, resolve } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+
37
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
38
+ const args = process.argv.slice(2);
39
+
40
+ if (args.includes("--help") || args.includes("-h") || args.length === 0) {
41
+ console.log(`prune-css — emit a stylesheet with only the @godxjp/ui CSS layers your app uses
42
+
43
+ usage: node node_modules/@godxjp/ui/scripts/prune-css.mjs <src dir/glob …> [--out <file>] [--fonts]
44
+
45
+ <src dir/glob …> where your app's source lives (directories are walked; globs expand)
46
+ --out <file> where to write the css (default: godx-ui.css in the current directory)
47
+ --fonts include the bundled M PLUS 2 / Noto Sans JP @font-face declarations
48
+ (default mirrors @godxjp/ui/styles/core: none — bring your own fonts)
49
+
50
+ Then import the emitted file INSTEAD of "@godxjp/ui/styles" and re-run this command whenever
51
+ the set of components you use changes, and after every @godxjp/ui upgrade.`);
52
+ process.exit(0);
53
+ }
54
+
55
+ const outFlag = args.indexOf("--out");
56
+ const OUT = outFlag !== -1 ? args[outFlag + 1] : "godx-ui.css";
57
+ if (outFlag !== -1 && (!OUT || OUT.startsWith("--"))) {
58
+ console.error("prune-css: --out needs a file path");
59
+ process.exit(2);
60
+ }
61
+ const FONTS = args.includes("--fonts");
62
+ const positionals = args.filter(
63
+ (a, i) => !a.startsWith("--") && (outFlag === -1 || i !== outFlag + 1),
64
+ );
65
+
66
+ // ── the package's own graph, version-locked ──────────────────────────────────
67
+
68
+ const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
69
+ const manifestPath = join(PKG_ROOT, "dist/styles/layers.json");
70
+ if (!existsSync(manifestPath)) {
71
+ console.error(
72
+ `prune-css: ${manifestPath} not found — the installed @godxjp/ui build does not ship the ` +
73
+ "layer manifest (upgrade the package; inside the repo itself, run `pnpm build` first).",
74
+ );
75
+ process.exit(2);
76
+ }
77
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
78
+ if (manifest.version !== pkg.version) {
79
+ console.error(
80
+ `prune-css: refusing to emit — the layer manifest says ${manifest.version ?? "null"} but the ` +
81
+ `installed @godxjp/ui is ${pkg.version}. A slice computed from another release's dependency ` +
82
+ "graph can silently miss a layer, which is the exact failure this tool exists to prevent. " +
83
+ "Reinstall @godxjp/ui (or rebuild dist/ if this is a linked checkout) and re-run.",
84
+ );
85
+ process.exit(2);
86
+ }
87
+
88
+ // ── scan consumer sources ────────────────────────────────────────────────────
89
+
90
+ const SCANNABLE = /\.(tsx|jsx|ts|mjs|js|cjs|mts)$/;
91
+
92
+ function walk(dir, into) {
93
+ for (const name of readdirSync(dir)) {
94
+ if (name === "node_modules" || name.startsWith(".")) continue;
95
+ const p = join(dir, name);
96
+ if (statSync(p).isDirectory()) walk(p, into);
97
+ else if (SCANNABLE.test(name)) into.push(p);
98
+ }
99
+ }
100
+
101
+ const sources = [];
102
+ for (const arg of positionals) {
103
+ const asPath = resolve(process.cwd(), arg);
104
+ if (existsSync(asPath) && statSync(asPath).isDirectory()) walk(asPath, sources);
105
+ else if (existsSync(asPath) && SCANNABLE.test(asPath)) sources.push(asPath);
106
+ else {
107
+ for (const hit of globSync(arg, { cwd: process.cwd() })) {
108
+ const p = resolve(process.cwd(), hit);
109
+ if (statSync(p).isDirectory()) walk(p, sources);
110
+ else if (SCANNABLE.test(p)) sources.push(p);
111
+ }
112
+ }
113
+ }
114
+ if (sources.length === 0) {
115
+ console.error(
116
+ `prune-css: nothing to scan — no source files matched ${positionals.join(" ")}. ` +
117
+ "An empty scan must never emit an empty stylesheet, so this is an error, not a result.",
118
+ );
119
+ process.exit(2);
120
+ }
121
+
122
+ const componentNames = Object.keys(manifest.components);
123
+ // Longest name first, so `CardBar…` resolves before `Card` when matching sub-part imports.
124
+ const byLength = [...componentNames].sort((a, b) => b.length - a.length);
125
+
126
+ const used = new Set();
127
+ let sawNamespace = false;
128
+ const IMPORT =
129
+ /(?:import|export)\s+(type\s+)?([^;'"]*?)\s*from\s*["'](@godxjp\/ui(?:\/[^"']*)?)["']/g;
130
+ for (const file of new Set(sources)) {
131
+ const src = readFileSync(file, "utf8");
132
+ for (const m of src.matchAll(IMPORT)) {
133
+ const [, typeOnly, clause, spec] = m;
134
+ if (typeOnly) continue; // a type renders nothing
135
+ if (spec.startsWith("@godxjp/ui/styles")) continue; // css imports carry no components
136
+ if (/\*\s*as\s/.test(clause)) {
137
+ // `import * as UI` — anything may be rendered through it. Correct beats minimal:
138
+ // include everything rather than guess at property accesses.
139
+ sawNamespace = true;
140
+ continue;
141
+ }
142
+ const braces = /\{([^}]*)\}/.exec(clause);
143
+ if (!braces) continue; // a bare side-effect or default import names no component
144
+ for (let entry of braces[1].split(",")) {
145
+ entry = entry.trim();
146
+ if (!entry || entry.startsWith("type ")) continue;
147
+ const name = entry.split(/\s+as\s+/)[0].trim();
148
+ if (!/^[A-Z]/.test(name)) continue; // hooks and utils style nothing
149
+ // Sub-parts (SheetContent, DropdownMenuTrigger, CardHeader…) are not manifest rows;
150
+ // their root component is, and the root's layers cover the whole compound family.
151
+ const root = byLength.find((c) => name === c || name.startsWith(c));
152
+ if (root) used.add(root);
153
+ }
154
+ }
155
+ // A dynamic `import("@godxjp/ui…")` names no bindings this regex can see.
156
+ if (/import\s*\(\s*["']@godxjp\/ui(?!\/styles)/.test(src)) sawNamespace = true;
157
+ }
158
+
159
+ if (sawNamespace) {
160
+ console.error(
161
+ "prune-css: found a namespace or dynamic import of @godxjp/ui — cannot tell which " +
162
+ "components it renders, so ALL component layers are included (still no bundled fonts " +
163
+ "unless --fonts). Use named imports to get a real slice.",
164
+ );
165
+ for (const c of componentNames) used.add(c);
166
+ }
167
+ if (used.size === 0) {
168
+ console.error(
169
+ `prune-css: scanned ${sources.length} files and found no @godxjp/ui component imports — ` +
170
+ "check the paths you passed. Refusing to emit a component-less stylesheet.",
171
+ );
172
+ process.exit(2);
173
+ }
174
+
175
+ // ── closure → ordered emit ───────────────────────────────────────────────────
176
+
177
+ const layers = new Set();
178
+ const vendors = new Set();
179
+ for (const name of used) {
180
+ const entry = manifest.components[name];
181
+ for (const l of entry.layers) layers.add(l);
182
+ for (const v of entry.vendor ?? []) vendors.add(v);
183
+ }
184
+
185
+ const keptLayers = manifest.order.filter((f) => layers.has(f));
186
+ const keptVendors = manifest.vendor.filter((f) => vendors.has(f));
187
+ const asImport = (f) => `@import "@godxjp/ui/styles/${f.replace(/\.css$/, "")}";`;
188
+
189
+ const lines = [
190
+ "/*",
191
+ ` * GENERATED by @godxjp/ui prune-css — DO NOT EDIT.`,
192
+ ` *`,
193
+ ` * @godxjp/ui version: ${pkg.version}`,
194
+ ` * components detected: ${[...used].sort().join(", ")}`,
195
+ ` * layers: ${keptLayers.length} of ${manifest.order.length} (+base${FONTS ? "+fonts" : ", no bundled fonts — like styles/core"})`,
196
+ ` *`,
197
+ ` * Re-run when the components your app uses change, and after every @godxjp/ui upgrade:`,
198
+ ` * node node_modules/@godxjp/ui/scripts/prune-css.mjs <src …> --out <this file>${FONTS ? " --fonts" : ""}`,
199
+ ` *`,
200
+ ` * Hand-editing this file is the cherry-picking the README forbids: layers share rules and`,
201
+ ` * a missing one fails silently. Change your imports, then re-run the tool.`,
202
+ " */",
203
+ // Layer order must be declared before the first @import — same statement, same reasoning,
204
+ // as styles/index.css (vendor between base and components; see the comment there).
205
+ "@layer theme, base, vendor, components, utilities;",
206
+ ...keptVendors.map(asImport),
207
+ asImport(manifest.base),
208
+ ...(FONTS ? [asImport(manifest.fonts)] : []),
209
+ ...keptLayers.map(asImport),
210
+ "",
211
+ ];
212
+
213
+ writeFileSync(resolve(process.cwd(), OUT), lines.join("\n"));
214
+
215
+ const dropped = manifest.order.filter((f) => !layers.has(f));
216
+ console.error(
217
+ `prune-css: ${relative(process.cwd(), resolve(process.cwd(), OUT)) || OUT} — ` +
218
+ `${used.size} components across ${sources.length} files → ${keptLayers.length}/${manifest.order.length} layers` +
219
+ (keptVendors.length ? `, vendor: ${keptVendors.join(", ")}` : "") +
220
+ (dropped.length ? `\n dropped: ${dropped.join(", ")}` : "\n (every layer is in use)"),
221
+ );