@robomous/ui-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +38 -0
  3. package/components.json +13 -0
  4. package/dist/index.d.ts +44 -0
  5. package/dist/index.js +47 -0
  6. package/dist/lib/button.d.ts +1 -0
  7. package/dist/lib/button.js +4 -0
  8. package/dist/lib/cn.d.ts +18 -0
  9. package/dist/lib/cn.js +21 -0
  10. package/dist/lib/menu.d.ts +1 -0
  11. package/dist/lib/menu.js +16 -0
  12. package/dist/lib/progress.d.ts +3 -0
  13. package/dist/lib/progress.js +6 -0
  14. package/dist/lib/select.d.ts +1 -0
  15. package/dist/lib/select.js +3 -0
  16. package/dist/primitives/alert.d.ts +10 -0
  17. package/dist/primitives/alert.js +27 -0
  18. package/dist/primitives/badge.d.ts +9 -0
  19. package/dist/primitives/badge.js +28 -0
  20. package/dist/primitives/button.d.ts +10 -0
  21. package/dist/primitives/button.js +35 -0
  22. package/dist/primitives/card.d.ts +11 -0
  23. package/dist/primitives/card.js +24 -0
  24. package/dist/primitives/combobox.d.ts +24 -0
  25. package/dist/primitives/combobox.js +58 -0
  26. package/dist/primitives/dialog.d.ts +17 -0
  27. package/dist/primitives/dialog.js +36 -0
  28. package/dist/primitives/dropdown-menu.d.ts +29 -0
  29. package/dist/primitives/dropdown-menu.js +50 -0
  30. package/dist/primitives/field.d.ts +24 -0
  31. package/dist/primitives/field.js +67 -0
  32. package/dist/primitives/input-group.d.ts +16 -0
  33. package/dist/primitives/input-group.js +56 -0
  34. package/dist/primitives/input.d.ts +3 -0
  35. package/dist/primitives/input.js +6 -0
  36. package/dist/primitives/label.d.ts +4 -0
  37. package/dist/primitives/label.js +7 -0
  38. package/dist/primitives/progress.d.ts +4 -0
  39. package/dist/primitives/progress.js +7 -0
  40. package/dist/primitives/select.d.ts +15 -0
  41. package/dist/primitives/select.js +35 -0
  42. package/dist/primitives/separator.d.ts +4 -0
  43. package/dist/primitives/separator.js +8 -0
  44. package/dist/primitives/sheet.d.ts +14 -0
  45. package/dist/primitives/sheet.js +36 -0
  46. package/dist/primitives/skeleton.d.ts +2 -0
  47. package/dist/primitives/skeleton.js +6 -0
  48. package/dist/primitives/sonner.d.ts +4 -0
  49. package/dist/primitives/sonner.js +40 -0
  50. package/dist/primitives/table.d.ts +10 -0
  51. package/dist/primitives/table.js +27 -0
  52. package/dist/primitives/tabs.d.ts +11 -0
  53. package/dist/primitives/tabs.js +28 -0
  54. package/dist/primitives/textarea.d.ts +3 -0
  55. package/dist/primitives/textarea.js +6 -0
  56. package/dist/primitives/tooltip.d.ts +7 -0
  57. package/dist/primitives/tooltip.js +17 -0
  58. package/dist/statusTone.d.ts +37 -0
  59. package/dist/statusTone.js +38 -0
  60. package/dist/tokens.d.ts +26 -0
  61. package/dist/tokens.js +96 -0
  62. package/gates/canonical.test.mjs +71 -0
  63. package/gates/extensions.test.mjs +373 -0
  64. package/gates/index.d.mts +51 -0
  65. package/gates/index.mjs +514 -0
  66. package/gates/tokens.test.mjs +276 -0
  67. package/package.json +60 -0
  68. package/shadcn/README.md +13 -0
  69. package/shadcn/alert.tsx +76 -0
  70. package/shadcn/badge.tsx +49 -0
  71. package/shadcn/button.tsx +67 -0
  72. package/shadcn/card.tsx +103 -0
  73. package/shadcn/combobox.tsx +299 -0
  74. package/shadcn/dialog.tsx +166 -0
  75. package/shadcn/dropdown-menu.tsx +267 -0
  76. package/shadcn/field.tsx +236 -0
  77. package/shadcn/input-group.tsx +154 -0
  78. package/shadcn/input.tsx +19 -0
  79. package/shadcn/label.tsx +22 -0
  80. package/shadcn/progress.tsx +29 -0
  81. package/shadcn/select.tsx +190 -0
  82. package/shadcn/separator.tsx +28 -0
  83. package/shadcn/sheet.tsx +145 -0
  84. package/shadcn/skeleton.tsx +13 -0
  85. package/shadcn/sonner.tsx +49 -0
  86. package/shadcn/table.tsx +114 -0
  87. package/shadcn/tabs.tsx +88 -0
  88. package/shadcn/textarea.tsx +18 -0
  89. package/shadcn/tooltip.tsx +57 -0
  90. package/src/harness.test.tsx +37 -0
  91. package/src/index.ts +119 -0
  92. package/src/lib/button.ts +4 -0
  93. package/src/lib/cn.test.ts +33 -0
  94. package/src/lib/cn.ts +23 -0
  95. package/src/lib/menu.ts +16 -0
  96. package/src/lib/progress.ts +6 -0
  97. package/src/lib/select.ts +4 -0
  98. package/src/primitives/alert.tsx +76 -0
  99. package/src/primitives/badge.tsx +57 -0
  100. package/src/primitives/button.tsx +67 -0
  101. package/src/primitives/card.tsx +103 -0
  102. package/src/primitives/combobox.test.tsx +38 -0
  103. package/src/primitives/combobox.tsx +299 -0
  104. package/src/primitives/dialog.tsx +166 -0
  105. package/src/primitives/dropdown-menu.tsx +267 -0
  106. package/src/primitives/field.tsx +236 -0
  107. package/src/primitives/input-group.tsx +154 -0
  108. package/src/primitives/input.tsx +19 -0
  109. package/src/primitives/label.tsx +22 -0
  110. package/src/primitives/primitives.test.tsx +440 -0
  111. package/src/primitives/progress.tsx +29 -0
  112. package/src/primitives/select.tsx +190 -0
  113. package/src/primitives/separator.tsx +28 -0
  114. package/src/primitives/sheet.tsx +145 -0
  115. package/src/primitives/skeleton.tsx +13 -0
  116. package/src/primitives/sonner.test.tsx +41 -0
  117. package/src/primitives/sonner.tsx +65 -0
  118. package/src/primitives/table.tsx +114 -0
  119. package/src/primitives/tabs.tsx +88 -0
  120. package/src/primitives/textarea.tsx +18 -0
  121. package/src/primitives/tooltip.tsx +57 -0
  122. package/src/statusTone.test.ts +20 -0
  123. package/src/statusTone.ts +52 -0
  124. package/src/styles.css +204 -0
  125. package/src/tokens.test.ts +285 -0
  126. package/src/tokens.ts +102 -0
@@ -0,0 +1,514 @@
1
+ /**
2
+ * @robomous/ui-core/gates — the pure helpers and foundation facts that keep
3
+ * the design system honest, published so every consumer repo can run the
4
+ * same gates over its own sources with its own extensions registry.
5
+ * Lifted from VisionSet's original repo-root gate tests.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { readFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const PKG = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
+
14
+ const COMMENT = /^\s*(?:\/\/|\/\*|\*|#)/;
15
+
16
+ // ---- canonical (from tests/scripts/shadcn_canonical.test.mjs) ----
17
+
18
+ const lines = (text) => text.split(/\r?\n/).map((l) => l.trimEnd());
19
+
20
+ // Every snapshot line must appear in the primitive, in order. Added lines are
21
+ // the only permitted difference — that is the whole of the "do not modify
22
+ // shadcn's code" rule, in a form a machine can check.
23
+ export function additiveOnly(snapshot, actual) {
24
+ const want = lines(snapshot);
25
+ const have = lines(actual);
26
+ let cursor = 0;
27
+ for (const line of want) {
28
+ const at = have.indexOf(line, cursor);
29
+ if (at === -1) return { ok: false, missing: line };
30
+ cursor = at + 1;
31
+ }
32
+ return { ok: true };
33
+ }
34
+
35
+ // A primitive may replace a framework-specific hook (shadcn's Next.js
36
+ // integrations) with a thin adapter that reads the one theme source
37
+ // instead — never a shortcut for divergence in general. `FRAMEWORK_ADAPTERS`
38
+ // is the explicit allow-list; the marker comment `SHADCN FRAMEWORK ADAPTER`
39
+ // is how a primitive claims the exemption, and it must be on the list to
40
+ // claim it. `ADAPTER_REMOVED_LINES` are exactly the snapshot lines the
41
+ // adapter is permitted to drop — every other snapshot line must still appear,
42
+ // in order, same as any other primitive.
43
+ export const FRAMEWORK_ADAPTERS = ["sonner.tsx"];
44
+ export const ADAPTER_REMOVED_LINES = ['import { useTheme } from "next-themes"', ' const { theme = "system" } = useTheme()'];
45
+
46
+ export function withoutLines(text, removed) {
47
+ const removedTrimmed = new Set(removed.map((l) => l.trimEnd()));
48
+ return lines(text)
49
+ .filter((line) => !removedTrimmed.has(line))
50
+ .join("\n");
51
+ }
52
+
53
+ // Decides which comparison a primitive gets. Returns { ok: false, reason }
54
+ // when the marker is present on a file that isn't allow-listed; otherwise
55
+ // { ok: true, isAdapter } says whether the adapter-adjusted snapshot applies.
56
+ export function checkAdapter(file, actualText) {
57
+ if (!actualText.includes("SHADCN FRAMEWORK ADAPTER")) return { ok: true, isAdapter: false };
58
+ if (!FRAMEWORK_ADAPTERS.includes(file)) {
59
+ return { ok: false, reason: `${file} carries the SHADCN FRAMEWORK ADAPTER marker but is not in FRAMEWORK_ADAPTERS` };
60
+ }
61
+ return { ok: true, isAdapter: true };
62
+ }
63
+
64
+ // ---- rosters and vocabulary (from tests/scripts/shadcn_extensions.test.mjs) ----
65
+
66
+ /** The variant keys of the first `variant: { … }` block in a cva source. */
67
+ export function variantKeys(source, block = "variant") {
68
+ const start = source.indexOf(`${block}: {`);
69
+ assert.notEqual(start, -1, `no "${block}" block`);
70
+ let depth = 0, i = start + block.length + 3;
71
+ const begin = i;
72
+ for (; i < source.length; i++) {
73
+ if (source[i] === "{") depth++;
74
+ else if (source[i] === "}") { if (depth === 0) break; depth--; }
75
+ }
76
+ const body = source.slice(begin, i);
77
+ return [...body.matchAll(/(?:^|,)\s*"?([a-z][a-z0-9-]*)"?:\s/g)].map((m) => m[1]);
78
+ }
79
+
80
+ /** The class string of one variant line in a cva source. */
81
+ export function variantClasses(source, key) {
82
+ const m = source.match(new RegExp(String.raw`^\s*"?${key}"?:\s*\n?\s*"([^"]*)"`, "m"));
83
+ assert.ok(m, `no variant ${key}`);
84
+ return m[1];
85
+ }
86
+
87
+ export const OFFICIAL_BADGE = ["default", "secondary", "destructive", "outline", "ghost", "link"];
88
+ export const FOUNDATION_BADGE = ["success", "warning", "info", "quiet"];
89
+ export const BUTTON_VARIANTS = ["default", "outline", "secondary", "ghost", "destructive", "link"];
90
+ export const BUTTON_SIZES = ["default", "xs", "sm", "lg", "icon", "icon-xs", "icon-sm", "icon-lg"];
91
+
92
+ /**
93
+ * v1's vocabulary the extension contract retired: prop names and shapes no
94
+ * shadcn primitive carries, and the PascalCase import paths and framework
95
+ * package v1 read them from.
96
+ *
97
+ * Every forbidden token is assembled from fragments — the trick
98
+ * `RETIRED_DECLARATIONS` below uses too — so none of them sits contiguously
99
+ * anywhere in this file's own source.
100
+ */
101
+ const NEXT_THEMES = ["next", "themes"].join("-");
102
+ const FIELD_HINT = ["Field", "Hint"].join("");
103
+ const TABLE_EMPTY = ["Table", "Empty"].join("");
104
+ const LEGACY_PRIMITIVES = [
105
+ "Badge",
106
+ "Button",
107
+ "Card",
108
+ "Combobox",
109
+ "Dialog",
110
+ "Feedback",
111
+ "Input",
112
+ "Menu",
113
+ "Select",
114
+ "Table",
115
+ "Tabs",
116
+ ];
117
+
118
+ /**
119
+ * `Badge`'s `quiet` variant is the shape for a soft, borderless status chip —
120
+ * an `outline` Badge hand-rounded with a `rounded-*` utility on `className`
121
+ * is that same shape built by hand, so it is forbidden wherever both
122
+ * attributes land on the one tag (in either order).
123
+ */
124
+ const BADGE_OUTLINE_ROUNDED = /(?=[\s\S]*\bvariant="outline")(?=[\s\S]*\bclassName="[^"]*\brounded-)/;
125
+
126
+ const TAG_ATTRIBUTE_RULES = [
127
+ { tag: "Button", attribute: /variant="(?:primary|success)"|size="(?:md)"/ },
128
+ { tag: "Badge", attribute: /variant="(?:neutral|accent)"/ },
129
+ { tag: "Badge", attribute: BADGE_OUTLINE_ROUNDED },
130
+ { tag: "Progress", attribute: /\bvariant=/ },
131
+ { tag: "SelectItem", attribute: /\bmeta=/ },
132
+ { tag: "Alert", attribute: /\btitle=/ },
133
+ ];
134
+ const LEGACY_IMPORT = new RegExp(
135
+ String.raw`from\s+["'][^"']*/primitives/(?:${LEGACY_PRIMITIVES.join("|")})(?:\.js)?["']`,
136
+ );
137
+ const NEXT_THEMES_IMPORT = new RegExp(String.raw`from\s+["']${NEXT_THEMES}["']`);
138
+ const BARE_LEGACY_NAME = new RegExp(String.raw`\b(?:${FIELD_HINT}|${TABLE_EMPTY})\b`);
139
+
140
+ /**
141
+ * The same retired names, written the way a test reads them back off the DOM
142
+ * rather than the way a component spells them. `TAG_ATTRIBUTE_RULES` walks JSX
143
+ * opening tags only, so a spec asserting v1's vocabulary sails straight past
144
+ * it — which is how `cycle.spec.ts` kept `"primary"` through the whole
145
+ * realignment, with CI the only thing that ever caught it.
146
+ *
147
+ * Listed here are the values **no** primitive carries any more, so naming one
148
+ * is wrong wherever it lands: `primary` belongs to no variant block at all,
149
+ * `Badge` dropped `neutral` and `accent`, and `Button`'s `md` is the official
150
+ * `default`. `secondary` and `success` are deliberately absent — both are
151
+ * still real variants, so an assertion naming one asks a question about its
152
+ * own call site that no vocabulary sweep can answer.
153
+ */
154
+ const RETIRED_ATTRIBUTE_VALUES = { variant: ["primary", "neutral", "accent"], size: ["md"] };
155
+
156
+ /**
157
+ * Three shapes per attribute, because a spec has three ways to say the one
158
+ * thing: `toHaveAttribute("data-variant", "primary")`, a
159
+ * `[data-variant="primary"]` selector, and `getAttribute("data-variant")` or
160
+ * `dataset.variant` compared with `toBe`/`toEqual`. The first crosses newlines
161
+ * on purpose — prettier puts the two arguments on their own lines once the
162
+ * call runs long, and that wrapped form is exactly the one that got through.
163
+ */
164
+ const RETIRED_ATTRIBUTE_ASSERTIONS = Object.entries(RETIRED_ATTRIBUTE_VALUES).flatMap(
165
+ ([name, values]) => {
166
+ const value = `(?:${values.join("|")})`;
167
+ return [
168
+ new RegExp(String.raw`"data-${name}"\s*,\s*"${value}"`, "g"),
169
+ new RegExp(String.raw`\[data-${name}="${value}"\]`, "g"),
170
+ new RegExp(
171
+ String.raw`(?:getAttribute\("data-${name}"\)|dataset\.${name})[^\n]*?\.to(?:Be|Equal)\(\s*"${value}"`,
172
+ "g",
173
+ ),
174
+ ];
175
+ },
176
+ );
177
+
178
+ /** The 1-based line of `text` that character offset `index` falls on. */
179
+ function lineAt(text, index) {
180
+ return text.slice(0, index).split("\n").length;
181
+ }
182
+
183
+ /**
184
+ * The index just past a JSX opening tag's real closing `>`, starting the scan
185
+ * at `start` (the tag's own `<`). A `[^>]*?` regex is fooled by any literal
186
+ * `>` — an arrow function, a `count > 0` comparison, a `>` inside a quoted
187
+ * string — so this instead walks the text tracking `{}` depth (a `>` only
188
+ * ends the tag at depth zero) and skips over `"…"`/`'…'`/`` `…` `` bodies
189
+ * wholesale, wherever they appear, so a `>` quoted inside one is never read
190
+ * as the tag's own.
191
+ */
192
+ function openTagEnd(text, start) {
193
+ let depth = 0;
194
+ for (let i = start; i < text.length; i++) {
195
+ const ch = text[i];
196
+ if (ch === '"' || ch === "'" || ch === "`") {
197
+ const quote = ch;
198
+ i++;
199
+ while (i < text.length && text[i] !== quote) {
200
+ if (text[i] === "\\") i++;
201
+ i++;
202
+ }
203
+ continue;
204
+ }
205
+ if (ch === "{") depth++;
206
+ else if (ch === "}") depth--;
207
+ else if (ch === ">" && depth === 0) return i + 1;
208
+ }
209
+ return text.length;
210
+ }
211
+
212
+ /** Every `<Tag …>` opening tag in `text`, as `{ at, tag }` in source order. */
213
+ export function openTagsIn(text, tagName) {
214
+ const starts = new RegExp(String.raw`<${tagName}\b`, "g");
215
+ return [...text.matchAll(starts)].map((m) => ({
216
+ at: m.index,
217
+ tag: text.slice(m.index, openTagEnd(text, m.index)),
218
+ }));
219
+ }
220
+
221
+ /** Every `file:line` in `text` reaching for a name the extension contract retired. */
222
+ export function legacyVocabularyIn(file, text) {
223
+ const scrubbed = text
224
+ .split("\n")
225
+ .map((line) => (COMMENT.test(line) ? "" : line))
226
+ .join("\n");
227
+ const hits = [];
228
+
229
+ for (const { tag, attribute } of TAG_ATTRIBUTE_RULES) {
230
+ for (const { at, tag: tagText } of openTagsIn(scrubbed, tag)) {
231
+ if (attribute.test(tagText)) hits.push({ at: lineAt(scrubbed, at), text: tagText.split("\n")[0].trim() });
232
+ }
233
+ }
234
+
235
+ for (const matcher of RETIRED_ATTRIBUTE_ASSERTIONS) {
236
+ for (const { 0: found, index } of scrubbed.matchAll(matcher)) {
237
+ hits.push({ at: lineAt(scrubbed, index), text: found.replace(/\s+/g, " ").trim() });
238
+ }
239
+ }
240
+
241
+ scrubbed.split("\n").forEach((line, index) => {
242
+ if (BARE_LEGACY_NAME.test(line) || LEGACY_IMPORT.test(line) || NEXT_THEMES_IMPORT.test(line)) {
243
+ hits.push({ at: index + 1, text: line.trim() });
244
+ }
245
+ });
246
+
247
+ return hits
248
+ .sort((a, b) => a.at - b.at)
249
+ .map(({ at, text: t }) => `${file}:${at}: ${t}`);
250
+ }
251
+
252
+ /**
253
+ * The status palette's one Tailwind family: emerald/amber/sky, across every
254
+ * prefix that can carry a colour. `Badge` and `statusTone.ts` are its one
255
+ * home — see `statusTone.ts`'s own docstring — so a third place naming the
256
+ * family is a fork of the palette, not a use of it.
257
+ */
258
+ const STATUS_PALETTE = /\b(?:bg|text|border|ring|fill|stroke|from|to|via|outline|decoration|shadow)-(?:emerald|amber|sky)-\d/;
259
+
260
+ /** Every `file:line` in `text` painting with the status palette, outside a comment. */
261
+ export function statusPaletteIn(file, text) {
262
+ return text
263
+ .split("\n")
264
+ .map((line, index) => ({ line, at: index + 1 }))
265
+ .filter(({ line }) => !COMMENT.test(line) && STATUS_PALETTE.test(line))
266
+ .map(({ line, at }) => `${file}:${at}: ${line.trim()}`);
267
+ }
268
+
269
+ /**
270
+ * A colour family that competes with the status palette for the same job —
271
+ * "a warning", "a success" — and so could stand in for it undetected. Unlike
272
+ * the palette itself these have no allowed home anywhere in a consumer.
273
+ */
274
+ const COMPETING_PALETTE = /\b(?:bg|text|border)-(?:green|lime|teal|yellow|orange|blue|cyan|red)-\d/;
275
+
276
+ /** Every `file:line` in `text` reaching for a colour family that competes with the status palette. */
277
+ export function competingStatusPaletteIn(file, text) {
278
+ return text
279
+ .split("\n")
280
+ .map((line, index) => ({ line, at: index + 1 }))
281
+ .filter(({ line }) => !COMMENT.test(line) && COMPETING_PALETTE.test(line))
282
+ .map(({ line, at }) => `${file}:${at}: ${line.trim()}`);
283
+ }
284
+
285
+ /**
286
+ * `--success`/`--warning` and their `-foreground` companions are retired
287
+ * declarations (`retiredDeclarationsIn` guards the stylesheet itself); this is
288
+ * the other half — no consumer may reach for the *utility* either, on any of
289
+ * the four prefixes that could carry one.
290
+ */
291
+ const STATUS_TOKEN_UTILITY = /\b(?:bg|text|border|ring)-(?:success|warning)(?:-foreground)?\b/;
292
+
293
+ /** Every `file:line` in `text` reaching for the retired status token utility, outside a comment. */
294
+ export function statusTokenUtilitiesIn(file, text) {
295
+ return text
296
+ .split("\n")
297
+ .map((line, index) => ({ line, at: index + 1 }))
298
+ .filter(({ line }) => !COMMENT.test(line) && STATUS_TOKEN_UTILITY.test(line))
299
+ .map(({ line, at }) => `${file}:${at}: ${line.trim()}`);
300
+ }
301
+
302
+ /**
303
+ * Every `DropdownMenuContent` call site owns `lib/menu.ts`'s `menuSurface` —
304
+ * the constant that carries the canonical surface classes plus the exit-
305
+ * animation and sizing fixes documented there. A call site that drops it
306
+ * silently reverts to the bare Radix surface.
307
+ */
308
+
309
+ /** Every `file:line` in `text` opening a `DropdownMenuContent` without `menuSurface` on it. */
310
+ export function menuSurfaceGapsIn(file, text) {
311
+ return openTagsIn(text, "DropdownMenuContent")
312
+ .filter(({ tag }) => !tag.includes("menuSurface"))
313
+ .map(({ at, tag }) => `${file}:${lineAt(text, at)}: ${tag.split("\n")[0].trim()}`);
314
+ }
315
+
316
+ // ---- colour discipline (from tests/scripts/design_tokens.test.mjs) ----
317
+
318
+ /**
319
+ * A Tailwind arbitrary value whose content is a colour.
320
+ *
321
+ * Assembled from fragments so this file does not match itself. The `-` before
322
+ * the bracket is what makes it a *utility* rather than an array index or a
323
+ * TypeScript tuple type.
324
+ */
325
+ const HEX = ["#", "[0-9a-fA-F]{3,8}"].join("");
326
+ const LITERAL = String.raw`(?:${HEX}|rgba?\(|hsla?\(|oklch\()`;
327
+ // A raw colour right inside the bracket…
328
+ const ARBITRARY_COLOUR = new RegExp(String.raw`-\[\s*(?:${LITERAL}|var\(\s*--)`);
329
+ // The one colour-mix the preset writes mixes tokens only:
330
+ // `color-mix(in_oklch,var(--secondary),var(--foreground)_5%)`. Any other
331
+ // argument shape — a literal, a named colour, a bare number — is a colour.
332
+ const TOKEN_MIX = String.raw`color-mix\(in_[a-z0-9-]+(?:,var\(--[a-z0-9-]+\)(?:_\d+(?:\.\d+)?%)?)+\)`;
333
+ const BRACKET_MIX = new RegExp(String.raw`-\[\s*color-mix\([^\]]*\]`);
334
+ const ALLOWED_MIX = new RegExp(String.raw`-\[\s*${TOKEN_MIX}\]`);
335
+
336
+ /** Every `file:line` in `text` that puts a colour inside a Tailwind class. */
337
+ export function colouredClassesIn(file, text) {
338
+ return text
339
+ .split("\n")
340
+ .map((line, index) => ({ line, at: index + 1 }))
341
+ .filter(
342
+ ({ line }) =>
343
+ !COMMENT.test(line) &&
344
+ (ARBITRARY_COLOUR.test(line) || (BRACKET_MIX.test(line) && !ALLOWED_MIX.test(line))),
345
+ )
346
+ .map(({ line, at }) => `${file}:${at}: ${line.trim()}`);
347
+ }
348
+
349
+ /**
350
+ * `DESIGN.md` "Where the brand is": coral is identity, not a functional-UI
351
+ * colour — the wordmark and the styleguide swatch that shows it off, nothing
352
+ * a person acts on. This is not a headcount: the gate does not exist to hold
353
+ * a count of sites, it exists so brand can never migrate onto a control (a
354
+ * button, a progress fill, anything with a function) instead of staying the
355
+ * one place it is allowed to just be seen. A pure function over one file's
356
+ * text, so the gate is provable with fabricated input, and `COMMENT` keeps
357
+ * the styles.css line that *states* the rule from counting as a usage of it.
358
+ */
359
+ const BRAND_UTILITY = /\b(?:bg|text|border|ring|fill|stroke)-brand\b/;
360
+
361
+ /** Every line in `text` that paints with the brand colour. */
362
+ export function brandUsagesIn(file, text) {
363
+ return text
364
+ .split("\n")
365
+ .map((line, index) => ({ line, at: index + 1 }))
366
+ .filter(({ line }) => !COMMENT.test(line) && BRAND_UTILITY.test(line))
367
+ .map(({ line, at }) => ({ file, at, text: line.trim() }));
368
+ }
369
+
370
+ /**
371
+ * The names the original audit retired outright — no shadcn analogue, no
372
+ * extension, no idiom left to fall back to. `tokens.test.ts` already guards
373
+ * this structurally, parsed against `styles.css`'s own `:root`/`.dark`
374
+ * blocks; this is the same guard by a different method, on purpose — a
375
+ * plain-text scan that keeps working even if that vitest suite is ever
376
+ * refactored or its parser changes shape.
377
+ *
378
+ * Assembled from fragments — the trick `HEX` above already uses — so none of
379
+ * these names is a contiguous string anywhere in this file's own source, and
380
+ * a repo-wide sweep for one of them never mistakes this guard for a
381
+ * lingering usage.
382
+ */
383
+ const dash = (...parts) => parts.join("-");
384
+ const RETIRED_DECLARATIONS = [
385
+ dash("--color", "primary", "hover"),
386
+ dash("--color", "disabled"),
387
+ dash("--color", "disabled", "foreground"),
388
+ dash("--color", "success", "hover"),
389
+ dash("--color", "destructive", "foreground"),
390
+ dash("--color", "sidebar", "strong"),
391
+ dash("--color", "sidebar", "muted"),
392
+ dash("--text", "meta"),
393
+ dash("--text", "body"),
394
+ dash("--text", "section"),
395
+ dash("--text", "page"),
396
+ dash("--spacing", "sidebar", "mobile"),
397
+ dash("--success"),
398
+ dash("--success", "foreground"),
399
+ dash("--warning"),
400
+ dash("--warning", "foreground"),
401
+ dash("--color", "success"),
402
+ dash("--color", "success", "foreground"),
403
+ dash("--color", "warning"),
404
+ dash("--color", "warning", "foreground"),
405
+ ];
406
+
407
+ /** Every retired name in `text` declared as a custom property, not merely mentioned. */
408
+ export function retiredDeclarationsIn(text) {
409
+ return text
410
+ .split("\n")
411
+ .map((line, index) => ({ line, at: index + 1 }))
412
+ .filter(({ line }) => !COMMENT.test(line))
413
+ .flatMap(({ line, at }) =>
414
+ RETIRED_DECLARATIONS.filter((name) => line.includes(`${name}:`)).map((name) => `${at}: ${name}`),
415
+ );
416
+ }
417
+
418
+ // ---- stylesheet parsing (from src/tokens.test.ts, translated TS→JS) ----
419
+
420
+ /** Whitespace is presentation; a value that wraps is the same value. */
421
+ export function normalize(value) {
422
+ return value.replace(/\s+/g, " ").trim();
423
+ }
424
+
425
+ /**
426
+ * The `{ … }` body belonging to the block whose header (e.g. `:root {` or
427
+ * `.dark {`) appears first in the file, matched by brace depth rather than by
428
+ * the next `\n}` so a nested `calc(...)` or `color-mix(...)` cannot fool it.
429
+ */
430
+ export function blockBody(css, header) {
431
+ const headerAt = css.indexOf(header);
432
+ if (headerAt === -1) throw new Error(`stylesheet has no ${JSON.stringify(header)} block`);
433
+ const braceAt = css.indexOf("{", headerAt);
434
+ let depth = 1;
435
+ let i = braceAt + 1;
436
+ while (depth > 0 && i < css.length) {
437
+ if (css[i] === "{") depth++;
438
+ else if (css[i] === "}") depth--;
439
+ i++;
440
+ }
441
+ return css.slice(braceAt + 1, i - 1);
442
+ }
443
+
444
+ /** Every `--name: value;` declaration in a block, keyed WITH the leading `--`. */
445
+ export function rawDeclarations(block) {
446
+ const map = new Map();
447
+ for (const match of block.matchAll(/(--[\w-]+):\s*([^;]+);/g)) {
448
+ map.set(match[1], normalize(match[2]));
449
+ }
450
+ return map;
451
+ }
452
+
453
+ /** The same, keyed WITHOUT the leading `--` — the shape `LIGHT_THEME` uses. */
454
+ export function declarations(block) {
455
+ const map = new Map();
456
+ for (const [name, value] of rawDeclarations(block)) {
457
+ map.set(name.slice(2), value);
458
+ }
459
+ return map;
460
+ }
461
+
462
+ // The shadcn standard vocabulary, exactly as the CLI scratch emitted it —
463
+ // not derived from `tokens.ts`, so a mistake in the mirror cannot also
464
+ // erase the thing it was supposed to mirror.
465
+ export const SEMANTIC_NAMES = [
466
+ "background",
467
+ "foreground",
468
+ "card",
469
+ "card-foreground",
470
+ "popover",
471
+ "popover-foreground",
472
+ "primary",
473
+ "primary-foreground",
474
+ "secondary",
475
+ "secondary-foreground",
476
+ "muted",
477
+ "muted-foreground",
478
+ "accent",
479
+ "accent-foreground",
480
+ "destructive",
481
+ "border",
482
+ "input",
483
+ "ring",
484
+ "chart-1",
485
+ "chart-2",
486
+ "chart-3",
487
+ "chart-4",
488
+ "chart-5",
489
+ "sidebar",
490
+ "sidebar-foreground",
491
+ "sidebar-primary",
492
+ "sidebar-primary-foreground",
493
+ "sidebar-accent",
494
+ "sidebar-accent-foreground",
495
+ "sidebar-border",
496
+ "sidebar-ring",
497
+ ];
498
+
499
+ // ---- foundation facts ----
500
+
501
+ /** Absolute path to the canonical shadcn snapshots shipped in this package. */
502
+ export function snapshotsDir() {
503
+ return path.join(PKG, "shadcn");
504
+ }
505
+
506
+ /**
507
+ * The foundation token names, read off the shipped stylesheet's `:root` so
508
+ * they can never drift from what actually runs. `radius` is a geometry
509
+ * setting, not a colour token, and is excluded — it matches LIGHT_THEME's keys.
510
+ */
511
+ export function foundationTokenNames() {
512
+ const css = readFileSync(path.join(PKG, "src/styles.css"), "utf8");
513
+ return [...declarations(blockBody(css, ":root {")).keys()].filter((n) => n !== "radius");
514
+ }