@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.
- package/LICENSE +202 -0
- package/README.md +38 -0
- package/components.json +13 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +47 -0
- package/dist/lib/button.d.ts +1 -0
- package/dist/lib/button.js +4 -0
- package/dist/lib/cn.d.ts +18 -0
- package/dist/lib/cn.js +21 -0
- package/dist/lib/menu.d.ts +1 -0
- package/dist/lib/menu.js +16 -0
- package/dist/lib/progress.d.ts +3 -0
- package/dist/lib/progress.js +6 -0
- package/dist/lib/select.d.ts +1 -0
- package/dist/lib/select.js +3 -0
- package/dist/primitives/alert.d.ts +10 -0
- package/dist/primitives/alert.js +27 -0
- package/dist/primitives/badge.d.ts +9 -0
- package/dist/primitives/badge.js +28 -0
- package/dist/primitives/button.d.ts +10 -0
- package/dist/primitives/button.js +35 -0
- package/dist/primitives/card.d.ts +11 -0
- package/dist/primitives/card.js +24 -0
- package/dist/primitives/combobox.d.ts +24 -0
- package/dist/primitives/combobox.js +58 -0
- package/dist/primitives/dialog.d.ts +17 -0
- package/dist/primitives/dialog.js +36 -0
- package/dist/primitives/dropdown-menu.d.ts +29 -0
- package/dist/primitives/dropdown-menu.js +50 -0
- package/dist/primitives/field.d.ts +24 -0
- package/dist/primitives/field.js +67 -0
- package/dist/primitives/input-group.d.ts +16 -0
- package/dist/primitives/input-group.js +56 -0
- package/dist/primitives/input.d.ts +3 -0
- package/dist/primitives/input.js +6 -0
- package/dist/primitives/label.d.ts +4 -0
- package/dist/primitives/label.js +7 -0
- package/dist/primitives/progress.d.ts +4 -0
- package/dist/primitives/progress.js +7 -0
- package/dist/primitives/select.d.ts +15 -0
- package/dist/primitives/select.js +35 -0
- package/dist/primitives/separator.d.ts +4 -0
- package/dist/primitives/separator.js +8 -0
- package/dist/primitives/sheet.d.ts +14 -0
- package/dist/primitives/sheet.js +36 -0
- package/dist/primitives/skeleton.d.ts +2 -0
- package/dist/primitives/skeleton.js +6 -0
- package/dist/primitives/sonner.d.ts +4 -0
- package/dist/primitives/sonner.js +40 -0
- package/dist/primitives/table.d.ts +10 -0
- package/dist/primitives/table.js +27 -0
- package/dist/primitives/tabs.d.ts +11 -0
- package/dist/primitives/tabs.js +28 -0
- package/dist/primitives/textarea.d.ts +3 -0
- package/dist/primitives/textarea.js +6 -0
- package/dist/primitives/tooltip.d.ts +7 -0
- package/dist/primitives/tooltip.js +17 -0
- package/dist/statusTone.d.ts +37 -0
- package/dist/statusTone.js +38 -0
- package/dist/tokens.d.ts +26 -0
- package/dist/tokens.js +96 -0
- package/gates/canonical.test.mjs +71 -0
- package/gates/extensions.test.mjs +373 -0
- package/gates/index.d.mts +51 -0
- package/gates/index.mjs +514 -0
- package/gates/tokens.test.mjs +276 -0
- package/package.json +60 -0
- package/shadcn/README.md +13 -0
- package/shadcn/alert.tsx +76 -0
- package/shadcn/badge.tsx +49 -0
- package/shadcn/button.tsx +67 -0
- package/shadcn/card.tsx +103 -0
- package/shadcn/combobox.tsx +299 -0
- package/shadcn/dialog.tsx +166 -0
- package/shadcn/dropdown-menu.tsx +267 -0
- package/shadcn/field.tsx +236 -0
- package/shadcn/input-group.tsx +154 -0
- package/shadcn/input.tsx +19 -0
- package/shadcn/label.tsx +22 -0
- package/shadcn/progress.tsx +29 -0
- package/shadcn/select.tsx +190 -0
- package/shadcn/separator.tsx +28 -0
- package/shadcn/sheet.tsx +145 -0
- package/shadcn/skeleton.tsx +13 -0
- package/shadcn/sonner.tsx +49 -0
- package/shadcn/table.tsx +114 -0
- package/shadcn/tabs.tsx +88 -0
- package/shadcn/textarea.tsx +18 -0
- package/shadcn/tooltip.tsx +57 -0
- package/src/harness.test.tsx +37 -0
- package/src/index.ts +119 -0
- package/src/lib/button.ts +4 -0
- package/src/lib/cn.test.ts +33 -0
- package/src/lib/cn.ts +23 -0
- package/src/lib/menu.ts +16 -0
- package/src/lib/progress.ts +6 -0
- package/src/lib/select.ts +4 -0
- package/src/primitives/alert.tsx +76 -0
- package/src/primitives/badge.tsx +57 -0
- package/src/primitives/button.tsx +67 -0
- package/src/primitives/card.tsx +103 -0
- package/src/primitives/combobox.test.tsx +38 -0
- package/src/primitives/combobox.tsx +299 -0
- package/src/primitives/dialog.tsx +166 -0
- package/src/primitives/dropdown-menu.tsx +267 -0
- package/src/primitives/field.tsx +236 -0
- package/src/primitives/input-group.tsx +154 -0
- package/src/primitives/input.tsx +19 -0
- package/src/primitives/label.tsx +22 -0
- package/src/primitives/primitives.test.tsx +440 -0
- package/src/primitives/progress.tsx +29 -0
- package/src/primitives/select.tsx +190 -0
- package/src/primitives/separator.tsx +28 -0
- package/src/primitives/sheet.tsx +145 -0
- package/src/primitives/skeleton.tsx +13 -0
- package/src/primitives/sonner.test.tsx +41 -0
- package/src/primitives/sonner.tsx +65 -0
- package/src/primitives/table.tsx +114 -0
- package/src/primitives/tabs.tsx +88 -0
- package/src/primitives/textarea.tsx +18 -0
- package/src/primitives/tooltip.tsx +57 -0
- package/src/statusTone.test.ts +20 -0
- package/src/statusTone.ts +52 -0
- package/src/styles.css +204 -0
- package/src/tokens.test.ts +285 -0
- package/src/tokens.ts +102 -0
package/gates/index.mjs
ADDED
|
@@ -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
|
+
}
|