@godxjp/ui 28.12.0 → 29.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/agent/START-HERE.md +29 -10
  2. package/agent/components/Anchor.json +6 -1
  3. package/agent/components/AppLauncher.json +10 -0
  4. package/agent/components/AppShell.json +1 -1
  5. package/agent/components/AreaChart.json +19 -1
  6. package/agent/components/Attachments.json +26 -1
  7. package/agent/components/BarChart.json +11 -1
  8. package/agent/components/BranchScopePicker.json +10 -0
  9. package/agent/components/Cascader.json +6 -1
  10. package/agent/components/Checkbox.json +6 -0
  11. package/agent/components/CompactBarTrend.json +1 -1
  12. package/agent/components/CredentialReveal.json +21 -0
  13. package/agent/components/DataState.json +1 -1
  14. package/agent/components/DataTable.json +4 -4
  15. package/agent/components/FormField.json +1 -1
  16. package/agent/components/InfiniteQueryState.json +1 -1
  17. package/agent/components/Input.json +1 -1
  18. package/agent/components/InputOTP.json +35 -0
  19. package/agent/components/LineChart.json +20 -2
  20. package/agent/components/ListRow.json +1 -1
  21. package/agent/components/Masonry.json +1 -1
  22. package/agent/components/MasterDetail.json +1 -1
  23. package/agent/components/PasswordStrength.json +1 -1
  24. package/agent/components/PermissionMatrix.json +6 -1
  25. package/agent/components/SearchInput.json +5 -0
  26. package/agent/components/Select.json +1 -0
  27. package/agent/components/ServiceRolePanel.json +5 -0
  28. package/agent/components/Sidebar.json +1 -1
  29. package/agent/components/Switch.json +6 -0
  30. package/agent/components/Table.json +8 -3
  31. package/agent/components/Tabs.json +10 -0
  32. package/agent/components/ThemeScope.json +49 -0
  33. package/agent/components/TimeRangePicker.json +5 -0
  34. package/agent/components/Topbar.json +1 -0
  35. package/agent/components/TopbarItem.json +2 -1
  36. package/agent/components/Transfer.json +6 -1
  37. package/agent/components/TreeSelect.json +1 -1
  38. package/agent/components/Upload.json +5 -0
  39. package/agent/components/UploadCropDialog.json +1 -1
  40. package/agent/components/formatDate.json +1 -1
  41. package/agent/components-index.json +5 -0
  42. package/agent/components.json +297 -31
  43. package/agent/index.json +19 -9
  44. package/agent/llms.txt +10 -10
  45. package/agent/patterns/tenant-brand-color.json +28 -0
  46. package/agent/patterns-index.json +27 -0
  47. package/agent/patterns.json +28 -0
  48. package/agent/rules.json +15 -0
  49. package/agent/tokens.json +4965 -970
  50. package/dist/app/index.d.ts +3 -0
  51. package/dist/app/index.js +3 -0
  52. package/dist/app/tenant-theme.d.ts +80 -0
  53. package/dist/app/tenant-theme.js +154 -0
  54. package/dist/app/theme-axes.d.ts +14 -1
  55. package/dist/app/theme-axes.js +24 -31
  56. package/dist/components/charts/chart-cartesian.d.ts +5 -1
  57. package/dist/components/charts/chart-cartesian.js +15 -8
  58. package/dist/components/data-display/badge.d.ts +1 -1
  59. package/dist/components/data-display/badge.js +20 -2
  60. package/dist/components/data-display/carousel.js +4 -4
  61. package/dist/components/data-display/data-table.js +13 -2
  62. package/dist/components/data-display/permission-matrix.js +1 -1
  63. package/dist/components/data-display/table.d.ts +11 -2
  64. package/dist/components/data-display/table.js +18 -2
  65. package/dist/components/data-entry/control-appearance.d.ts +12 -6
  66. package/dist/components/data-entry/control-appearance.js +1 -1
  67. package/dist/components/data-entry/select.js +4 -3
  68. package/dist/components/feedback/dialog.js +6 -3
  69. package/dist/components/feedback/overlay-header-tone.d.ts +7 -0
  70. package/dist/components/feedback/overlay-header-tone.js +4 -4
  71. package/dist/components/feedback/sheet.d.ts +1 -1
  72. package/dist/components/feedback/sheet.js +6 -9
  73. package/dist/components/feedback/sonner.js +16 -3
  74. package/dist/components/general/button.js +22 -5
  75. package/dist/components/layout/affix.js +15 -1
  76. package/dist/components/layout/sidebar.js +7 -1
  77. package/dist/components/navigation/anchor.d.ts +1 -1
  78. package/dist/components/navigation/anchor.js +5 -4
  79. package/dist/components/navigation/app-setting-picker.js +1 -1
  80. package/dist/components/navigation/pagination.js +1 -1
  81. package/dist/components/navigation/tabs.js +15 -2
  82. package/dist/components/query/infinite-query-state.d.ts +22 -6
  83. package/dist/contracts/measurement.json +1 -1
  84. package/dist/i18n/messages/en.json +0 -697
  85. package/dist/i18n/messages/ja.json +0 -691
  86. package/dist/i18n/messages/vi.json +0 -691
  87. package/dist/lib/control-styles.d.ts +31 -11
  88. package/dist/lib/control-styles.js +6 -6
  89. package/dist/lib/overlay-portal.d.ts +20 -0
  90. package/dist/lib/overlay-portal.js +93 -0
  91. package/dist/props/components/app.prop.d.ts +12 -0
  92. package/dist/props/components/charts.prop.d.ts +30 -0
  93. package/dist/props/components/index.d.ts +1 -1
  94. package/dist/props/components/navigation.prop.d.ts +21 -2
  95. package/dist/props/components/query.prop.d.ts +36 -2
  96. package/dist/props/registry.d.ts +46 -1
  97. package/dist/props/registry.js +38 -3
  98. package/dist/styles/alert-layout.css +34 -14
  99. package/dist/styles/badge-layout.css +10 -6
  100. package/dist/styles/base.css +14 -5
  101. package/dist/styles/card-layout.css +19 -8
  102. package/dist/styles/chart-layout.css +22 -3
  103. package/dist/styles/control.css +165 -59
  104. package/dist/styles/data-display-layout.css +129 -36
  105. package/dist/styles/data-entry-layout.css +23 -87
  106. package/dist/styles/dialog-layout.css +49 -19
  107. package/dist/styles/float-button-layout.css +5 -5
  108. package/dist/styles/focus-ring.css +9 -5
  109. package/dist/styles/layout.css +42 -15
  110. package/dist/styles/logo-layout.css +1 -1
  111. package/dist/styles/motion.css +1 -1
  112. package/dist/styles/navigation-layout.css +90 -29
  113. package/dist/styles/shell-layout.css +63 -40
  114. package/dist/styles/table-layout.css +56 -17
  115. package/dist/styles/text-layout.css +13 -4
  116. package/dist/styles/toggle.css +8 -2
  117. package/dist/tokens/components/actions.css +1 -1
  118. package/dist/tokens/components/attachments.css +4 -4
  119. package/dist/tokens/components/badge.css +4 -4
  120. package/dist/tokens/components/callout.css +1 -1
  121. package/dist/tokens/components/card.css +9 -4
  122. package/dist/tokens/components/chart.css +10 -1
  123. package/dist/tokens/components/chat-bubble.css +1 -1
  124. package/dist/tokens/components/control.css +28 -10
  125. package/dist/tokens/components/conversations.css +2 -1
  126. package/dist/tokens/components/data-display.css +12 -7
  127. package/dist/tokens/components/descriptions.css +1 -1
  128. package/dist/tokens/components/draggable-panel.css +1 -1
  129. package/dist/tokens/components/feedback.css +28 -8
  130. package/dist/tokens/components/float-button.css +1 -1
  131. package/dist/tokens/components/legal-document.css +1 -1
  132. package/dist/tokens/components/logo.css +1 -1
  133. package/dist/tokens/components/mega-menu.css +5 -3
  134. package/dist/tokens/components/navigation.css +21 -7
  135. package/dist/tokens/components/segmented.css +8 -3
  136. package/dist/tokens/components/shell.css +19 -5
  137. package/dist/tokens/components/table.css +9 -1
  138. package/dist/tokens/components/thought-chain.css +1 -1
  139. package/dist/tokens/components/toggle.css +2 -0
  140. package/dist/tokens/components/tree.css +3 -1
  141. package/dist/tokens/components/upload.css +6 -6
  142. package/dist/tokens/components/welcome.css +1 -1
  143. package/dist/tokens/foundation.css +28 -1
  144. package/docs/COMPOSITION-VS-COMPONENT.md +31 -0
  145. package/docs/CUSTOMER-THEMING.md +637 -1
  146. package/docs/DESIGN-AUTHORITY.md +13 -0
  147. package/docs/FRAME-COVERAGE-REPORT.md +3 -2
  148. package/docs/GLASSMORPHISM-STANDARD.md +196 -0
  149. package/docs/THEME-API-COVERAGE.md +538 -0
  150. package/docs/TOKEN-RESOLUTION.md +195 -0
  151. package/docs/TOKENS.md +63 -24
  152. package/docs/asset-modules.d.ts +7 -0
  153. package/docs/data-display/charts.tsx +80 -0
  154. package/docs/data-display/data-table/index.tsx +30 -0
  155. package/docs/data-display/popover.tsx +1 -1
  156. package/docs/data-display/table.tsx +52 -0
  157. package/docs/feedback/sheet.tsx +10 -10
  158. package/docs/foundation/density.tsx +4 -4
  159. package/docs/i18n/messages/en.json +1201 -0
  160. package/docs/i18n/messages/ja.json +1195 -0
  161. package/docs/i18n/messages/vi.json +1195 -0
  162. package/docs/layout/account-chip.tsx +2 -2
  163. package/docs/layout/responsive-grid.tsx +1 -1
  164. package/docs/navigation/toolbar.tsx +20 -12
  165. package/docs/providers/theme-scope.tsx +186 -0
  166. package/docs/showcase/caimono-price-comparison.tsx +911 -0
  167. package/docs/showcase/case4-login.tsx +2 -2
  168. package/docs/showcase/marketing-page.tsx +3 -2
  169. package/docs/showcase/permission-matrix.tsx +13 -5
  170. package/docs/showcase/table-pagination.tsx +2 -1
  171. package/docs/showcase/tenant-brand-color.tsx +338 -0
  172. package/docs/showcase/theme-customization.tsx +2 -1
  173. package/docs/showcase/theme-lab.tsx +2125 -0
  174. package/docs/themes/flat.css +462 -0
  175. package/docs/themes/glassmorphism.css +958 -0
  176. package/docs/themes/index.ts +200 -0
  177. package/package.json +4 -3
  178. package/scripts/explain-token.mjs +382 -0
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The theme REGISTRY for `/showcase/theme-lab` — the one file a new theme touches.
3
+ *
4
+ * gh#882 asks that adding a theme mean "one new CSS file and one new row, never a page edit".
5
+ * That is what this module is: the page imports `THEMES` and `SEEDS` and knows nothing else about
6
+ * either. A new theme is
7
+ *
8
+ * 1. `docs/themes/<id>.css`, containing NOTHING but `--custom-property` declarations under
9
+ * `[data-theme-style="<id>"]`, and
10
+ * 2. one row below (plus the side-effect import that puts the file in the bundle).
11
+ *
12
+ * WHY THE IMPORT IS WRITTEN OUT rather than globbed. `import.meta.glob` would make the row the
13
+ * only edit, but it is a Vite-only form typed by `vite/client`, and `tsconfig.json` pins
14
+ * `"types": ["node"]` — `tsconfig.docs.json` inherits that, so a glob here is a TS2339 that
15
+ * `typecheck:docs` catches and no amount of local success hides. Two lines in one non-page file is
16
+ * the honest cost of staying inside the type system the rest of `docs/**` is checked by.
17
+ *
18
+ * ── The constraint that makes this a MEASUREMENT (gh#882, and it is the whole exercise) ────────
19
+ * A theme file may declare custom properties and nothing else: no `backdrop-filter:`, no
20
+ * `background:`, no selector into a `.ui-*` class, no `className` or inline `style` on the page.
21
+ * Where a look cannot be expressed that way, the RESULT is the gap — written down in
22
+ * `docs/showcase/theme-lab.tsx`'s "what the token API could not reach" table — never worked around.
23
+ *
24
+ * ── Seeds ─────────────────────────────────────────────────────────────────────────────────────
25
+ * The second half of gh#882: nothing proved the glass theme was not tuned to violet. Every seed
26
+ * below goes through `tenantTheme(hex)` (`@godxjp/ui/app`), which returns the contrast-safe
27
+ * `--primary` / `--primary-foreground` pair plus the hover and pressed steps as real triplets, and
28
+ * the theme files derive their own decoration FROM `--primary` with CSS relative colour so the
29
+ * whole page follows the seed rather than the seed following the theme.
30
+ */
31
+ import "./glassmorphism.css";
32
+ import "./flat.css";
33
+
34
+ export type ThemeRow = {
35
+ /**
36
+ * The `data-theme-style` value, or `null` for the library's own default — the control. `null`
37
+ * is a row, not a special case: the page writes the attribute either way and an attribute that
38
+ * matches no selector is exactly "no theme".
39
+ */
40
+ id: string | null;
41
+ /** Message key for the switch label. */
42
+ nameKey: string;
43
+ /** Message key for the one-line note under the switch. */
44
+ noteKey: string;
45
+ /**
46
+ * The LIGHTEST surface brand INK lands on in this theme, as hex — handed to `tenantTheme` as
47
+ * `options.surface`. `null` means the library's own light default (`--accent` #ebe9e5).
48
+ *
49
+ * `tenantTheme` walks the three brand inks AWAY from a surface until they clear 4.5:1, and it
50
+ * has to be told which surface, because it emits them as literals in `style` — which outrank a
51
+ * theme's own `--text-link` however carefully the theme declared it. Omitting this on a DARK
52
+ * theme is not a missing nicety, it is the ink walked the WRONG WAY: measured on glass/citron,
53
+ * `tenantTheme(#FFD400)` alone emitted `--text-link: 49.88 100% 24.4%` — rgb(124,103,0), walked
54
+ * dark for the light default — over a #3b382b panel, so `Button variant="link"` and a `Text`
55
+ * link both read **2.09:1** while the theme's own `--foreground` next to them was near-white.
56
+ * The library says so at `src/app/tenant-theme.ts`: "a region inside a DARK theme must pass the
57
+ * dark surface or its ink is walked the wrong way."
58
+ *
59
+ * WHY ONE HEX COVERS FIVE SEEDS. Every glass surface is a translucent white over
60
+ * `--background`, which derives its hue from the seed, so the composited panel moves per seed.
61
+ * Composited and measured across all five: card #312b3b..#3b382b, popover #434851..#514f43,
62
+ * accent #482d76..#766a2d, muted #232a39..#393523. The value below is the LIGHTEST of the
63
+ * twenty — glass/citron's `--accent` — because ink walked away from a dark surface goes
64
+ * lighter, and lighter ink on a DARKER surface only gains contrast. Clearing the worst case
65
+ * clears all of them.
66
+ *
67
+ * A PAIR, not one hex (gh#896): the surface above was measured while glass was reachable in ONE
68
+ * polarity only — the whole theme rendered permanently dark (the bug gh#896 reports), so every
69
+ * hex the paragraph above lists, `#766A2D` included, is a DARK-branch measurement with no light
70
+ * counterpart. Now that polarity is addressable, both members below are `null` until a real
71
+ * compositing measurement exists for EACH branch — the old single hex is not carried over into
72
+ * either slot, because guessing which polarity it belongs to is exactly the mistake this pair
73
+ * exists to prevent. `null` means the library's own default for that polarity (light `--accent`
74
+ * #ebe9e5; dark reads the built-in dark spine).
75
+ */
76
+ inkSurface: { light: string | null; dark: string | null };
77
+ };
78
+
79
+ export type SeedRow = {
80
+ /** Stable id — also the `?seed=` query value, so a measurement can address it directly. */
81
+ id: string;
82
+ /** The customer hex handed to `tenantTheme()`. */
83
+ hex: string;
84
+ /** Message key for the swatch label. */
85
+ nameKey: string;
86
+ };
87
+
88
+ /**
89
+ * The polarity axis (gh#896): the library ships light AND dark, and until this row existed the lab
90
+ * had no way to address the dark half of a theme × seed cell — a theme's dark branch could regress
91
+ * silently because nothing ever asked for it. `id` is the exact string `applyThemeAxes`
92
+ * (`src/app/theme-axes.ts`) resolves `data-theme` to, so it can be handed straight to
93
+ * `AppProvider`'s own `setTheme` — never written to `document.documentElement` by this page itself.
94
+ */
95
+ export type ModeRow = {
96
+ /** Also the `?mode=` query value. */
97
+ id: "light" | "dark";
98
+ /** Message key for the switch label. */
99
+ nameKey: string;
100
+ };
101
+
102
+ /**
103
+ * Order is reading order in the switch. `base` leads because a control that is not first is a
104
+ * control nobody looks at.
105
+ */
106
+ export const THEMES: readonly ThemeRow[] = [
107
+ {
108
+ id: null,
109
+ nameKey: "themeLab.theme.base.name",
110
+ noteKey: "themeLab.theme.base.note",
111
+ /* `null` IS ONLY CORRECT IN LIGHT, AND THE 30-CELL RUN IS WHAT SHOWED IT. I wrote "the package
112
+ * default IS this surface in both polarities" here and the matrix answered: every dark `base`
113
+ * cell read 552/562, ten failures, identical at all five seeds. `tenantTheme`'s default is
114
+ * `INK_SURFACE_LIGHT` (#ebe9e5) and there is no dark counterpart, so `null` in the dark slot
115
+ * walks the brand ink AWAY FROM A LIGHT SURFACE and lands it dark on a dark page — the same
116
+ * defect gh#887 found for glass, in the package's own theme, seed-independent because the
117
+ * surface is wrong rather than the seed.
118
+ *
119
+ * The light slot stays `null` because there the default really is right: measured, the darkest
120
+ * ground under brand ink is #DFE2E5 against the constant's #EBE9E5, and light `base` reads
121
+ * 562/562 at every seed.
122
+ *
123
+ * #3C3A34, AND THE PAINTED-PIXEL MEASUREMENT PICKED THE WRONG ONE (gh#903). Sampling the
124
+ * actual pixel under each brand-ink element gave #3C3619 and I took it, on the reasoning that a
125
+ * painted pixel beats a token composite. It does — but I sampled every element AT REST, and the
126
+ * ground a hovered MegaMenu trigger sits on is `--accent`, which is #3C3A34 and LIGHTER. For a
127
+ * dark theme the hardest ground is the LIGHTEST one, so the darker sample sent the ink the
128
+ * wrong way by a hair: it walked to L 74.9% instead of 76.4%, and the hovered label measured
129
+ * 4.23:1 on four of five seeds.
130
+ *
131
+ * The method was right and its COVERAGE was not. A ground only exists at rest if nothing ever
132
+ * changes it; an interaction state is a different ground and has to be sampled too. */
133
+ inkSurface: { light: null, dark: "#3C3A34" },
134
+ },
135
+ {
136
+ id: "glass",
137
+ nameKey: "themeLab.theme.glass.name",
138
+ noteKey: "themeLab.theme.glass.note",
139
+ /* THE LIGHT VALUE WAS MEASURED THE WRONG WAY THE FIRST TIME (gh#903). #B59FDB came from
140
+ * compositing the five surface TOKENS; the real ground is the PAINTED PIXEL under each
141
+ * brand-ink element, which includes the gradients a theme paints and the token set does not.
142
+ * Sampled with every glyph hidden, the darkest such ground across the five seeds is navy's
143
+ * #9493CC at L=0.3152 — darker than the #B59FDB (L=0.3973) I had, so the ink was not being
144
+ * walked far enough and 16 strings in light glass sat at 3.41-4.43:1.
145
+ *
146
+ * Measured per branch, and the two are not the same KIND of number (see the type above).
147
+ * `#766A2D` was right and stays on the dark side, where the hardest ground is the LIGHTEST
148
+ * surface; the light side is #B59FDB, the DARKEST surface, because the ink walks the other
149
+ * way there. Carrying one hex into both slots would have been wrong in exactly one of them. */
150
+ inkSurface: { light: "#9493CC", dark: "#766A2D" },
151
+ },
152
+ {
153
+ id: "flat",
154
+ nameKey: "themeLab.theme.flat.name",
155
+ noteKey: "themeLab.theme.flat.note",
156
+ /* Measured per branch now that flat has one. The light side is close enough to the package
157
+ * default that omitting it would very nearly work (#E4E1EA against #EBE9E5, both far above the
158
+ * pivot) — stated anyway, because "near enough to the default" is a fact about today's flat,
159
+ * not a contract it holds anyone to. */
160
+ inkSurface: { light: "#E4E1EA", dark: "#403D30" },
161
+ },
162
+ ];
163
+
164
+ /**
165
+ * Five seeds, chosen to break a theme rather than to flatter it: one cool default, one cool blue,
166
+ * one warm, one at the very light end (`#FFD400`, L≈83% — a white label on it fails AA, so
167
+ * `tenantTheme` picks black) and one at the very dark end (`#0A1F44`, L≈15% — the opposite).
168
+ * A theme tuned to a mid-luminance violet fails at both ends, which is the point of including them.
169
+ */
170
+ export const SEEDS: readonly SeedRow[] = [
171
+ { id: "violet", hex: "#7C3AED", nameKey: "themeLab.seed.violet" },
172
+ { id: "azure", hex: "#2563EB", nameKey: "themeLab.seed.azure" },
173
+ { id: "coral", hex: "#E2564A", nameKey: "themeLab.seed.coral" },
174
+ { id: "citron", hex: "#FFD400", nameKey: "themeLab.seed.citron" },
175
+ { id: "navy", hex: "#0A1F44", nameKey: "themeLab.seed.navy" },
176
+ ];
177
+
178
+ /** `light` leads for the same reason `base` leads `THEMES`: the first option is the control. */
179
+ export const MODES: readonly ModeRow[] = [
180
+ { id: "light", nameKey: "themeLab.mode.light" },
181
+ { id: "dark", nameKey: "themeLab.mode.dark" },
182
+ ];
183
+
184
+ /** The `?theme=` value for a row — `"base"` stands in for the null id in the URL. */
185
+ export const themeQueryValue = (row: ThemeRow): string => row.id ?? "base";
186
+
187
+ /** Resolve `?theme=` back to a row, falling back to the first one. */
188
+ export function themeFromQuery(value: string | null): ThemeRow {
189
+ return THEMES.find((row) => themeQueryValue(row) === value) ?? THEMES[0];
190
+ }
191
+
192
+ /** Resolve `?seed=` back to a row, falling back to the first one. */
193
+ export function seedFromQuery(value: string | null): SeedRow {
194
+ return SEEDS.find((row) => row.id === value) ?? SEEDS[0];
195
+ }
196
+
197
+ /** Resolve `?mode=` back to a row, falling back to the first one (light). */
198
+ export function modeFromQuery(value: string | null): ModeRow {
199
+ return MODES.find((row) => row.id === value) ?? MODES[0];
200
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "28.12.0",
4
- "godxUiMcp": "28.12.0",
3
+ "version": "29.0.0",
4
+ "godxUiMcp": "29.0.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -38,6 +38,7 @@
38
38
  "scripts/postinstall.mjs",
39
39
  "scripts/_agent-setup.mjs",
40
40
  "scripts/cli.mjs",
41
+ "scripts/explain-token.mjs",
41
42
  "docs"
42
43
  ],
43
44
  "main": "./dist/index.js",
@@ -392,7 +393,7 @@
392
393
  "lint:fix": "eslint . --fix",
393
394
  "postinstall": "node scripts/postinstall.mjs",
394
395
  "prepublishOnly": "pnpm run build",
395
- "preview": "node preview/scripts/kill-port.mjs && vite --config preview/vite.config.ts --port 6008 --strictPort",
396
+ "preview": "PORT=$(node preview/scripts/preview-port.mjs) && node preview/scripts/kill-port.mjs $PORT && echo \"preview: http://localhost:$PORT\" && vite --config preview/vite.config.ts --port $PORT --strictPort",
396
397
  "preview:build": "vite build --config preview/vite.config.ts && node scripts/gen-registry.mjs && node scripts/copy-agent-catalog.mjs",
397
398
  "regen": "node scripts/regen-generated.mjs",
398
399
  "release": "node scripts/release.mjs",
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * WHO SETS THIS TOKEN, WHO READS IT, AND WHO WOULD WIN — the resolution trace.
4
+ *
5
+ * WHY THIS EXISTS. The owner's complaint, verbatim: *"tao thấy rất nhiều chỗ mày cứ đè cấu hình
6
+ * lung tung làm ảnh hưởng component này sang component khác"* — configuration overriding
7
+ * configuration until a change to one component moves another. Prose cannot settle that argument.
8
+ * A trace can: for one token, print every DECLARATION and every READ in the package, in cascade
9
+ * order, and name the rule that would win.
10
+ *
11
+ * WHAT THE ORDER IS. See docs/TOKEN-RESOLUTION.md. In short, a custom property is resolved by the
12
+ * ordinary cascade, so the winner at an element is the declaration from the nearest ancestor that
13
+ * matched, and among equals the one with the highest specificity, and among those the last:
14
+ *
15
+ * 1 inline `style` on the element (a per-instance prop)
16
+ * 2 the nearest scope that declares it ([data-tenant], .dark, a region wrapper)
17
+ * 3 `:root` in the consumer's own theme.css (unlayered, so it beats every package layer)
18
+ * 4 `:root` in this package's token tier (the default)
19
+ *
20
+ * THE ONE RULE THAT BREAKS IT, and the reason this script reports `FREEZE` loudly: `var()`
21
+ * substitutes where it is DECLARED, not where it is read. So a package default written as
22
+ *
23
+ * :root { --card-border-color: var(--border); } ← FROZEN
24
+ *
25
+ * resolves against the ROOT's `--border` once, and a `[data-tenant]` below root that changes
26
+ * `--border` can never reach it — step 2 of the chain is silently dead for that token. The shape
27
+ * that keeps the chain alive is a knob of `initial` plus the formula at the CALL SITE:
28
+ *
29
+ * :root { --card-border-color: initial; }
30
+ * .ui-card { border-color: var(--card-border-color, hsl(var(--border))); }
31
+ *
32
+ * This repo has paid for that distinction seven times (gh#687, gh#843, gh#848, gh#866, …), which
33
+ * is why it is a reported finding here and not a footnote.
34
+ *
35
+ * USAGE
36
+ * node scripts/explain-token.mjs --card-border-color one token, full trace
37
+ * node scripts/explain-token.mjs --card every token matching a prefix
38
+ * node scripts/explain-token.mjs --audit every FREEZE and every orphan
39
+ * node scripts/explain-token.mjs --json <name> machine-readable, for a gate
40
+ */
41
+ import { readFileSync, readdirSync, statSync } from "node:fs";
42
+ import { join, relative } from "node:path";
43
+
44
+ const ROOT = process.cwd();
45
+
46
+ /** Source of truth for what a CONSUMER can set: the published catalog, not the stylesheets. */
47
+ function publishedTokens() {
48
+ try {
49
+ const raw = JSON.parse(readFileSync(join(ROOT, "agent/tokens.json"), "utf8"));
50
+ const list = Array.isArray(raw) ? raw : Object.values(raw).find(Array.isArray);
51
+ return new Map(list.map((t) => [t.name, t.tier ?? "component"]));
52
+ } catch {
53
+ return new Map();
54
+ }
55
+ }
56
+
57
+ function cssFiles() {
58
+ const out = [];
59
+ const walk = (dir) => {
60
+ for (const entry of readdirSync(dir)) {
61
+ const full = join(dir, entry);
62
+ if (statSync(full).isDirectory()) walk(full);
63
+ else if (entry.endsWith(".css")) out.push(full);
64
+ }
65
+ };
66
+ /* BOTH LAYOUTS, because the same script runs in two places and only one of them has `src/`.
67
+ * In this checkout the CSS lives in `src/tokens` and `src/styles`. In the PUBLISHED package it
68
+ * lives in `dist/tokens` and `dist/styles` — `package.json::files` ships `dist`, never `src`.
69
+ * gh#893 put this file in the tarball, and shipping it was not the same as making it work: a
70
+ * consumer running it against `node_modules/@godxjp/ui` scanned two directories that do not
71
+ * exist there, found zero CSS files, and got a confident empty answer for every token. Walking
72
+ * both and keeping whichever is present costs one loop and removes the whole failure mode. */
73
+ for (const dir of ["src/tokens", "src/styles", "dist/tokens", "dist/styles"]) {
74
+ try {
75
+ walk(join(ROOT, dir));
76
+ } catch {
77
+ /* a tree that is not there is not an error here */
78
+ }
79
+ }
80
+ return out.sort();
81
+ }
82
+
83
+ /**
84
+ * Every `--x: value` declaration, with the selector it sits under and the layer that governs it.
85
+ *
86
+ * Comments are BLANKED rather than removed so byte offsets stay true — a `{`, `}` or `;` inside
87
+ * prose otherwise desynchronises the brace walk and silently moves every selector after it. That
88
+ * failure has a name in this repo: it is what `check-mcp-prop-sync` hit with `=>` (gh#857).
89
+ */
90
+ function parse(file) {
91
+ const text = readFileSync(file, "utf8");
92
+ const blank = text.replace(/\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, " "));
93
+ const rel = relative(ROOT, file);
94
+ const lineAt = (i) => text.slice(0, i).split("\n").length;
95
+
96
+ const decls = [];
97
+ const reads = [];
98
+ const stack = [];
99
+ let selStart = 0;
100
+
101
+ for (let i = 0; i < blank.length; i += 1) {
102
+ const ch = blank[i];
103
+ if (ch === "{") {
104
+ stack.push(blank.slice(selStart, i).trim().replace(/\s+/g, " "));
105
+ selStart = i + 1;
106
+ } else if (ch === "}") {
107
+ stack.pop();
108
+ selStart = i + 1;
109
+ } else if (ch === ";") {
110
+ selStart = i + 1;
111
+ }
112
+ }
113
+
114
+ // Declarations: `--name: value;` — captured with their enclosing selector chain.
115
+ const declRe = /(--[a-z0-9-]+)\s*:\s*([^;}]+)[;}]/gi;
116
+ for (const m of blank.matchAll(declRe)) {
117
+ const line = lineAt(m.index);
118
+ const value = text.slice(m.index + m[1].length, m.index + m[0].length).replace(/^\s*:\s*/, "");
119
+ decls.push({
120
+ file: rel,
121
+ line,
122
+ name: m[1],
123
+ value: value
124
+ .replace(/[;}]\s*$/, "")
125
+ .trim()
126
+ .replace(/\s+/g, " "),
127
+ context: contextAt(blank, m.index),
128
+ });
129
+ }
130
+
131
+ // Reads: `var(--name` anywhere, including inside another declaration's value.
132
+ for (const m of blank.matchAll(/var\(\s*(--[a-z0-9-]+)/gi)) {
133
+ reads.push({
134
+ file: rel,
135
+ line: lineAt(m.index),
136
+ name: m[1],
137
+ context: contextAt(blank, m.index),
138
+ hasFallback: text
139
+ .slice(m.index, m.index + 400)
140
+ .replace(/\s+/g, " ")
141
+ .includes(`${m[1]},`),
142
+ });
143
+ }
144
+ return { decls, reads };
145
+ }
146
+
147
+ /** The selector chain enclosing a byte offset, outermost first. */
148
+ function contextAt(blank, at) {
149
+ const chain = [];
150
+ let depth = 0;
151
+ let selStart = 0;
152
+ for (let i = 0; i < at; i += 1) {
153
+ const ch = blank[i];
154
+ if (ch === "{") {
155
+ chain.push({ depth, sel: blank.slice(selStart, i).trim().replace(/\s+/g, " ") });
156
+ depth += 1;
157
+ selStart = i + 1;
158
+ } else if (ch === "}") {
159
+ depth -= 1;
160
+ while (chain.length && chain[chain.length - 1].depth >= depth) chain.pop();
161
+ selStart = i + 1;
162
+ } else if (ch === ";" && depth >= 0) {
163
+ selStart = i + 1;
164
+ }
165
+ }
166
+ return chain.map((c) => c.sel).filter(Boolean);
167
+ }
168
+
169
+ /**
170
+ * IS THIS DECLARATION ON THE ROOT ELEMENT? — the one thing here that is mechanically decidable,
171
+ * and the only thing the freeze test needs.
172
+ *
173
+ * An earlier version of this file ranked every selector into four "cascade" buckets by regex and
174
+ * printed them "strongest last". Codex took it apart and was right: `@theme inline` and
175
+ * `[dir="rtl"] .ui-actions[data-fade-in-inline]` both scored TOP precedence because their text
176
+ * contains the substring `inline`; `:root[data-brand="crm"]` scored "descendant scope" although it
177
+ * matches only the root; `[data-slot="card"][data-density="tight"]` — a declaration on the
178
+ * component itself — scored "ambient scope". And "strongest last" sorted by ALPHABETICAL FILE
179
+ * ORDER, not import order, so the ordering was decoration.
180
+ *
181
+ * A tool meant to settle override disputes that manufactures precedence from substrings is worse
182
+ * than no tool: it sends the reader to the wrong fix with confidence. So the ranking is gone. This
183
+ * prints WHERE a token is declared and read, and computes only the one property it can prove.
184
+ */
185
+ function isRootOnly(context) {
186
+ if (!context.length) return false;
187
+ return context.every((sel) =>
188
+ sel
189
+ .split(",")
190
+ .map((s) => s.trim())
191
+ .filter(Boolean)
192
+ .every((s) =>
193
+ // `:root`, `:root[data-theme="dark"]`, `html` — matches the root element and nothing
194
+ // below it. A descendant combinator, or any selector that can match an element deeper in
195
+ // the tree, is NOT root-only.
196
+ /^(:root|html)(\[[^\]]*\]|:[a-z-]+(\([^)]*\))?)*$/.test(s),
197
+ ),
198
+ );
199
+ }
200
+
201
+ /**
202
+ * Every token some declaration BELOW the root can move.
203
+ *
204
+ * Deliberately wider than "a theme scope": `.ui-page-container` inside `@media (max-width: 720px)`
205
+ * restates `--space-section-active` (src/styles/layout.css:992), and `--card-space-inset` binds it
206
+ * at `:root` (src/tokens/components/card.css:6) — so below 720px a Card keeps the root's inset. The
207
+ * previous version missed that because it only accepted selectors that LOOKED like theme scopes.
208
+ * Anything not root-only counts now.
209
+ */
210
+ function scopedTokenNames(decls) {
211
+ return new Set(decls.filter((d) => !isRootOnly(d.context)).map((d) => d.name));
212
+ }
213
+
214
+ function collect() {
215
+ const decls = [];
216
+ const reads = [];
217
+ for (const f of cssFiles()) {
218
+ const r = parse(f);
219
+ decls.push(...r.decls);
220
+ reads.push(...r.reads);
221
+ }
222
+ return { decls, reads };
223
+ }
224
+
225
+ /**
226
+ * A `:root` binding that a scope below root cannot reach.
227
+ *
228
+ * The naive test — "a `:root` declaration whose value contains `var()`" — reports 1028 sites here,
229
+ * and a finding list that long is one nobody reads. Most are harmless: `--actions-gap:
230
+ * var(--space-1)` freezes against a `--space-1` that no scope ever redeclares, so freezing it
231
+ * changes nothing that could ever have differed.
232
+ *
233
+ * The binding only COSTS something when the token it reads is itself restated somewhere below
234
+ * root — a `.dark` block, a `[data-tenant]`, a density or scale scope. Then the scope moves the
235
+ * source and the binding keeps the root's answer, which is the defect this repo has paid for
236
+ * seven times. So the signal is the INTERSECTION, and `scopedNames` is derived from the
237
+ * stylesheets on every run rather than hand-listed: a hand-kept list is what went blind in gh#854.
238
+ */
239
+ function isFrozen(d, scopedNames) {
240
+ if (!isRootOnly(d.context)) return false;
241
+ if (d.value.trim() === "initial") return false;
242
+ const reads = [...d.value.matchAll(/var\(\s*(--[a-z0-9-]+)/gi)].map((m) => m[1]);
243
+ return reads.some((r) => scopedNames.has(r));
244
+ }
245
+
246
+ function trace(name, { decls, reads }, published, scopedNames) {
247
+ const mine = decls.filter((d) => d.name === name);
248
+ const myReads = reads.filter((r) => r.name === name);
249
+ const tier = published.get(name);
250
+
251
+ console.log(`\n${name}`);
252
+ console.log(
253
+ ` published: ${tier ? `yes (${tier} tier)` : "NO — not in agent/tokens.json, so a consumer cannot discover it"}`,
254
+ );
255
+
256
+ if (!mine.length) {
257
+ console.log(" declared: nowhere — every read falls to its inline fallback, or to nothing");
258
+ } else {
259
+ console.log(
260
+ ` declared: ${mine.length} site(s) — DECLARATION SITES, not a cascade ranking; which one`,
261
+ );
262
+ console.log(
263
+ " wins at a given element depends on the DOM, and is not computed here.",
264
+ );
265
+ for (const d of mine.sort(
266
+ (a, b) => Number(isRootOnly(b.context)) - Number(isRootOnly(a.context)),
267
+ )) {
268
+ const frozen = isFrozen(d, scopedNames)
269
+ ? " ← FREEZE: binds at :root against a token a scope below DOES restate"
270
+ : "";
271
+ console.log(
272
+ ` ${isRootOnly(d.context) ? "root-only " : "below root"} ${d.file}:${d.line}`,
273
+ );
274
+ console.log(` ${d.context.join(" ") || ":root"} { ${name}: ${d.value} }${frozen}`);
275
+ }
276
+ }
277
+
278
+ console.log(` read by: ${myReads.length} site(s)`);
279
+ for (const r of myReads.slice(0, 12)) {
280
+ console.log(
281
+ ` ${r.file}:${r.line} ${r.context.join(" ") || "(top level)"}${r.hasFallback ? " [has a call-site fallback]" : " [NO fallback — undeclared means unset]"}`,
282
+ );
283
+ }
284
+ if (myReads.length > 12) console.log(` … and ${myReads.length - 12} more`);
285
+ return { name, tier, decls: mine, reads: myReads };
286
+ }
287
+
288
+ function audit({ decls, reads }, published) {
289
+ const scopedNames = scopedTokenNames(decls);
290
+ const frozen = decls.filter((d) => isFrozen(d, scopedNames));
291
+ const declaredNames = new Set(decls.map((d) => d.name));
292
+ const orphanReads = reads.filter((r) => !declaredNames.has(r.name) && !r.hasFallback);
293
+ const unpublished = [...declaredNames].filter((n) => !published.has(n));
294
+
295
+ console.log(
296
+ `\nFROZEN — a :root binding whose SOURCE a scope below root restates (${frozen.length})`,
297
+ );
298
+ console.log(
299
+ ` ${scopedNames.size} token(s) are restated in some scope. A :root binding that reads one of`,
300
+ );
301
+ console.log(" them keeps the root's answer, so step 2 of the chain is dead for that token.");
302
+ for (const d of frozen.slice(0, 40)) {
303
+ console.log(` ${d.file}:${d.line} ${d.name}: ${d.value}`);
304
+ }
305
+ if (frozen.length > 40) console.log(` … and ${frozen.length - 40} more`);
306
+
307
+ console.log(
308
+ `\nORPHAN READS — var(--x) with no declaration and no fallback (${orphanReads.length})`,
309
+ );
310
+ for (const r of orphanReads.slice(0, 20)) console.log(` ${r.file}:${r.line} ${r.name}`);
311
+ if (orphanReads.length > 20) console.log(` … and ${orphanReads.length - 20} more`);
312
+
313
+ console.log(
314
+ `\nUNPUBLISHED — declared in CSS but absent from agent/tokens.json (${unpublished.length})`,
315
+ );
316
+ console.log(" A consumer cannot discover these, so they are not part of the theme API.");
317
+ for (const n of unpublished.slice(0, 20)) console.log(` ${n}`);
318
+ if (unpublished.length > 20) console.log(` … and ${unpublished.length - 20} more`);
319
+
320
+ console.log("\nWHAT THIS CANNOT SEE — do not read a clean run as proof of no freeze:");
321
+ console.log(
322
+ " 1. CONSUMER CSS. Only this package's own token/style trees are scanned, so a token it never",
323
+ );
324
+ console.log(
325
+ " restates below root looks safe. `--shadow-md` binds `--shadow-color` at :root; a",
326
+ );
327
+ console.log(
328
+ " consumer's `[data-tenant] { --shadow-color: … }` cannot recolour it, and nothing here says so.",
329
+ );
330
+ console.log(" 2. `@supports` FALLBACKS. src/tokens/derived.css gives engines without relative");
331
+ console.log(
332
+ " colour LITERAL hover/active values, so a tenant seed stops propagating — a lost derivation,",
333
+ );
334
+ console.log(" not a var() freeze, and invisible to a test that requires a var() reference.");
335
+ console.log(
336
+ " 3. CONDITIONS ARE NOT EVALUATED. A declaration inside @media/@container is counted as if it",
337
+ );
338
+ console.log(
339
+ " always applies. That widens the scoped set deliberately, but it is not the cascade.",
340
+ );
341
+ console.log(
342
+ " 4. NO WINNER IS COMPUTED. Which declaration applies at an element depends on the DOM.",
343
+ );
344
+
345
+ console.log(
346
+ `\nsummary: ${declaredNames.size} declared · ${published.size} published · ${frozen.length} frozen · ${orphanReads.length} orphan read(s)`,
347
+ );
348
+ return {
349
+ frozen: frozen.length,
350
+ orphanReads: orphanReads.length,
351
+ unpublished: unpublished.length,
352
+ };
353
+ }
354
+
355
+ const args = process.argv.slice(2);
356
+ const asJson = args.includes("--json");
357
+ const query = args.filter((a) => a !== "--json" && a !== "--audit")[0];
358
+ const model = collect();
359
+ const published = publishedTokens();
360
+
361
+ if (args.includes("--audit") || !query) {
362
+ if (!query && !args.includes("--audit")) {
363
+ console.log("usage: node scripts/explain-token.mjs <--token-name|prefix|--audit> [--json]\n");
364
+ }
365
+ audit(model, published);
366
+ } else {
367
+ const names = [...new Set(model.decls.map((d) => d.name))]
368
+ .concat([...published.keys()])
369
+ .filter((n, i, a) => a.indexOf(n) === i)
370
+ .filter((n) => n === query || n.startsWith(query));
371
+ if (!names.length) {
372
+ console.log(`no token matches ${query}`);
373
+ process.exit(1);
374
+ }
375
+ const scopedNames = scopedTokenNames(model.decls);
376
+ const out = names
377
+ .sort()
378
+ .slice(0, 40)
379
+ .map((n) => trace(n, model, published, scopedNames));
380
+ if (asJson) console.log(JSON.stringify(out, null, 2));
381
+ if (names.length > 40) console.log(`\n… ${names.length - 40} more match ${query}`);
382
+ }