@vegastack/design 0.7.0 → 0.7.1

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/bin/doctor.mjs CHANGED
@@ -12,11 +12,17 @@
12
12
  // command and not a paragraph in a guide. Every check here maps to a documented failure
13
13
  // mode in the Troubleshooting guide.
14
14
  //
15
+ // It also scans the project's OWN source for vocabulary the shadcn reset retired (0.5.0 → now):
16
+ // `text-h2`, `bg-destructive-subtle`, `var(--z-toast)`, an `@/components/ui/icon-button` import.
17
+ // A retired utility compiles to NOTHING — no build error, no type error — so a heading silently
18
+ // renders as body text. The Regent consumer carried 338 such classes past every other check
19
+ // (2026-09-23), which is why this is a failing check and not a guide section.
20
+ //
15
21
  // Read-only: it never writes, installs, or edits. Exit 0 = all good, 1 = a real problem,
16
22
  // so it composes into CI as `vegastack-design doctor`.
17
23
 
18
24
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
19
- import { dirname, join, relative } from "node:path";
25
+ import { dirname, join, relative, resolve, sep } from "node:path";
20
26
 
21
27
  const POSTCSS_CONFIGS = [
22
28
  "postcss.config.mjs",
@@ -41,6 +47,8 @@ const USAGE = `
41
47
  Usage: vegastack-design doctor [options]
42
48
 
43
49
  Checks a consuming project's VegaStack setup and reports what is wrong and how to fix it.
50
+ Also scans the project's own source (not node_modules, build output, or the components.json
51
+ \`ui\` alias directory) for vocabulary the shadcn reset retired, and reports each as file:line.
44
52
 
45
53
  Options:
46
54
  --dir <path> Project root to inspect (default: the current directory)
@@ -114,6 +122,469 @@ function readJson(path) {
114
122
  }
115
123
  }
116
124
 
125
+ // ---- retired vocabulary ----------------------------------------------------------------------
126
+ // Each entry: a pattern over one source line, a replacement hint (from the "Migrating to the shadcn
127
+ // reset" guide, where every row has its full table), and — for token families a project may
128
+ // legitimately define for itself — the custom property whose DECLARATION in the scanned source
129
+ // means "this is yours, not ours". Kept here and dependency-free on purpose: doctor must run in a
130
+ // fresh consumer before anything else is installed correctly.
131
+ const GUIDE = "https://design.vegastack.com/docs/guides/migrating-shadcn-reset";
132
+ const HEADING_HINT = {
133
+ "text-h1": "text-3xl font-semibold (page heading)",
134
+ "text-h2": "text-2xl font-semibold (section heading)",
135
+ "text-h3": "text-lg font-semibold",
136
+ "text-h4": "text-base font-medium (card / dialog title)",
137
+ };
138
+ const DISPLAY_HINT = {
139
+ sm: "text-4xl",
140
+ md: "text-5xl",
141
+ lg: "text-6xl",
142
+ xl: "text-7xl",
143
+ };
144
+ // The literal each deleted `--alpha-*` token carried. Derived from the token HISTORY, not only the
145
+ // last pre-reset snapshot: every name ever declared in packages/design-tokens (git log -S over
146
+ // src/tokens.ts and tokens/*.tokens.json) is here. Nineteen are the migration guide's section 4.2
147
+ // ladder, retired by the shadcn reset; five were retired before it, per the design-tokens
148
+ // CHANGELOG — fill-hover, input-hover and surface-subtle with `track` (#77), soft-hover and
149
+ // soft-surface with the Bubble contrast fix (#113). A theme-split token gives [light, dark].
150
+ const ALPHA = {
151
+ "fill-hover": 80,
152
+ "input-hover": 50,
153
+ "surface-subtle": 10,
154
+ "soft-surface": [10, 20],
155
+ "soft-hover": [20, 30],
156
+ "surface-faint": 5,
157
+ hover: 7,
158
+ border: 8,
159
+ pressed: 10,
160
+ "ink-tint": 10,
161
+ "ink-tint-strong": 15,
162
+ "border-subtle": 20,
163
+ "border-soft": 30,
164
+ input: 30,
165
+ "wash-faint": 40,
166
+ wash: 50,
167
+ "outline-border": 50,
168
+ "outline-soft": 50,
169
+ "wash-strong": 60,
170
+ "backdrop-soft": 60,
171
+ "tint-border": 70,
172
+ "link-hover": 88,
173
+ glass: 90,
174
+ "glass-hover": 95,
175
+ };
176
+ const OPACITY = { track: 25, dim: 50, "hint-soft": 60, hint: 70 };
177
+ const Z = {
178
+ raised: "z-10",
179
+ overlay: "z-50 — one overlay band, DOM order decides",
180
+ toast:
181
+ "nothing — the Toast viewport sets its own z-60, the one exception to z-50",
182
+ };
183
+ const RETIRED_COMPONENTS = {
184
+ "icon-button":
185
+ 'Button with size="icon" (or icon-sm / icon-lg) and an aria-label',
186
+ segmented: "a joined ToggleGroup",
187
+ "password-input":
188
+ "an InputGroup composition (Input + an InputGroupButton reveal toggle)",
189
+ "progress-indicator": "Progress (determinate) or Spinner (indeterminate)",
190
+ "field-inline": "EditableCell",
191
+ "floating-surface": "Popover / HoverCard — FloatingSurface was internal only",
192
+ "section-header":
193
+ "nothing — removed with the marketing layer; compose your own heading",
194
+ sonner:
195
+ "toast (`@/components/ui/toast`, `toast.add({ title })`) — Sonner was retired in 0.12.0",
196
+ };
197
+ const B = String.raw`(?<![\w-])`; // a utility or custom property starts here
198
+ const E = String.raw`(?![\w-])`; // …and ends here
199
+ /** An alternation of exactly these names, longest first so `border-subtle` wins over `border`. */
200
+ const oneOf = (names) =>
201
+ [...names].sort((a, b) => b.length - a.length).join("|");
202
+ // The four STATUS families are the only ones that ever shipped a `-subtle` step (guide section 4.5);
203
+ // `--tag-*-subtle` is kept, and a `muted`/`accent`/project family never had one, so neither is matched.
204
+ const SUBTLE_FAMILIES = ["destructive", "success", "warning", "info"];
205
+ export const RETIRED_VOCABULARY = [
206
+ {
207
+ id: "text-h*",
208
+ re: new RegExp(`${B}text-h[1-4]${E}`, "g"),
209
+ hint: (m) => HEADING_HINT[m],
210
+ declared: (m) => `--text-${m.slice(5)}`,
211
+ },
212
+ {
213
+ id: "text-label*",
214
+ re: new RegExp(`${B}text-label(?:-sm)?${E}`, "g"),
215
+ hint: (m) =>
216
+ m.endsWith("-sm") ? "text-xs font-medium" : "text-sm font-medium",
217
+ declared: (m) => `--${m}`,
218
+ },
219
+ {
220
+ id: "text-mono-label",
221
+ re: new RegExp(`${B}text-mono-label${E}`, "g"),
222
+ hint: () =>
223
+ "font-mono text-xs for code/data — a label is font-sans text-xs font-medium; no tracking, no uppercase",
224
+ declared: (m) => `--${m}`,
225
+ },
226
+ {
227
+ id: "text-code*",
228
+ re: new RegExp(`${B}text-code(?:-sm)?${E}`, "g"),
229
+ hint: (m) =>
230
+ m.endsWith("-sm") ? "font-mono text-xs" : "font-mono text-sm",
231
+ declared: (m) => `--${m}`,
232
+ },
233
+ {
234
+ id: "text-strong",
235
+ re: new RegExp(`${B}text-strong${E}`, "g"),
236
+ hint: () => "text-sm font-semibold",
237
+ declared: (m) => `--${m}`,
238
+ },
239
+ {
240
+ id: "text-display-*",
241
+ re: new RegExp(`${B}text-display-(?:sm|md|lg|xl)${E}`, "g"),
242
+ hint: (m) =>
243
+ `${DISPLAY_HINT[m.slice(13)]} — the ramp supplies tracking; write no tracking-*`,
244
+ declared: (m) => `--${m}`,
245
+ },
246
+ {
247
+ // The four STATUS families lost their -subtle step — and only those four ever had one, so the
248
+ // hint's `text-<family>-text` always names a real token.
249
+ id: "*-subtle",
250
+ re: new RegExp(
251
+ `${B}(?:bg|text|border|ring|outline|fill|stroke|divide|from|to|via)-(${SUBTLE_FAMILIES.join("|")})-subtle(?:-hover|-active)?${E}`,
252
+ "g",
253
+ ),
254
+ hint: (m, family) => {
255
+ return m.includes("-subtle-")
256
+ ? `hover:bg-${family}/20 (the -hover/-active steps are gone)`
257
+ : `bg-${family}/10 (a tint of the family), with text-${family}-text for text on it`;
258
+ },
259
+ declared: (m) => {
260
+ const name = m.replace(/^[a-z]+-/, "");
261
+ return [`--${name}`, `--color-${name}`];
262
+ },
263
+ },
264
+ {
265
+ // Only the names the system shipped (see ALPHA). `--alpha-*` is an open prefix other
266
+ // libraries use too, and a name we never shipped is not ours to call retired.
267
+ id: "--alpha-*",
268
+ re: new RegExp(`${B}--alpha-(?:${oneOf(Object.keys(ALPHA))})${E}`, "g"),
269
+ hint: (m) => {
270
+ const pct = ALPHA[m.slice(8)];
271
+ if (Array.isArray(pct))
272
+ return `the literal /${pct[0]} in light with a dark: variant at /${pct[1]}, e.g. bg-destructive/${pct[0]} dark:bg-destructive/${pct[1]}`;
273
+ return `the literal /${pct}, e.g. bg-foreground/${pct}`;
274
+ },
275
+ declared: (m) => m,
276
+ },
277
+ {
278
+ id: "--opacity-*",
279
+ re: new RegExp(`${B}--opacity-(?:${oneOf(Object.keys(OPACITY))})${E}`, "g"),
280
+ hint: (m) => `opacity-${OPACITY[m.slice(10)]}`,
281
+ declared: (m) => m,
282
+ },
283
+ {
284
+ // `--z-index`, `--z-modal`, … belong to other libraries; only the three named bands are ours.
285
+ id: "--z-*",
286
+ re: new RegExp(`${B}--z-(?:${oneOf(Object.keys(Z))})${E}`, "g"),
287
+ hint: (m) => Z[m.slice(4)],
288
+ declared: (m) => m,
289
+ },
290
+ {
291
+ id: "shadow-overlay",
292
+ re: new RegExp(`${B}(?:--)?shadow-overlay${E}`, "g"),
293
+ hint: () => "shadow-md (a popover) or shadow-lg (a modal)",
294
+ declared: () => "--shadow-overlay",
295
+ },
296
+ {
297
+ id: "backdrop-blur-glass",
298
+ re: new RegExp(`${B}backdrop-blur-glass${E}`, "g"),
299
+ hint: () =>
300
+ "delete it — the glass effect went with the marketing layer (backdrop-blur-xs is upstream's scrim blur)",
301
+ declared: () => "--blur-glass",
302
+ },
303
+ {
304
+ id: "retired component",
305
+ // An import/require/dynamic-import specifier whose LAST path segment is a retired item — and
306
+ // only when the specifier resolves INTO the ui alias directory (`accept`): a project's own
307
+ // `@/components/layout/section-header` or `./sonner` is its code, not a registry copy. A bare
308
+ // `"sonner"` (the npm package) is not a registry import and is left alone.
309
+ re: new RegExp(
310
+ String.raw`(?:from\s+|import\s*\(\s*|require\s*\(\s*|import\s+)["']([^"']*)\/(${Object.keys(RETIRED_COMPONENTS).join("|")})(?:\.[jt]sx?)?["']`,
311
+ "g",
312
+ ),
313
+ accept: (m, ctx) => importsIntoUi(m[1], ctx),
314
+ hint: (_m, _dir, name) => RETIRED_COMPONENTS[name],
315
+ declared: () => null,
316
+ label: (_m, _dir, name) => `import …/${name}`,
317
+ },
318
+ ];
319
+
320
+ // Code and stylesheets only. Prose (`.md`/`.mdx`) that NAMES a retired token — a changelog, a
321
+ // migration note — is not a use of it, and would bury the real findings.
322
+ const SCAN_EXTENSIONS = /\.(?:[cm]?[jt]sx?|css|scss)$/;
323
+ const SCAN_SKIP_DIRS = new Set([
324
+ "node_modules",
325
+ "dist",
326
+ "build",
327
+ "out",
328
+ "coverage",
329
+ "storybook-static",
330
+ ]);
331
+ const SCAN_MAX_FILES = 20000;
332
+ const SCAN_MAX_BYTES = 1024 * 1024;
333
+
334
+ /**
335
+ * The directories components.json's `ui` alias names, resolved against the file's own folder.
336
+ * Those files are registry copies — `check-updates` owns them, and a re-pull replaces them — so
337
+ * a retired name inside one is not the consumer's own code. `@/x` and `~/x` resolve to `x` or
338
+ * `src/x`, whichever exists (the shadcn convention for a src-dir project).
339
+ */
340
+ export function uiAliasDirs(componentsJson, componentsDir) {
341
+ const alias = componentsJson?.aliases?.ui;
342
+ const candidates = [];
343
+ if (typeof alias === "string" && alias.length > 0) {
344
+ const bare = alias.replace(/^(?:@|~)\//, "");
345
+ candidates.push(
346
+ join(componentsDir, bare),
347
+ join(componentsDir, "src", bare),
348
+ );
349
+ } else {
350
+ candidates.push(
351
+ join(componentsDir, "components", "ui"),
352
+ join(componentsDir, "src", "components", "ui"),
353
+ );
354
+ }
355
+ return candidates.filter((d) => existsSync(d)).map((d) => resolve(d));
356
+ }
357
+
358
+ /**
359
+ * Does an import specifier (the part before the retired name) resolve into the ui alias directory?
360
+ * Three spellings do: the components.json `ui` alias (`@/components/ui`), the registry's own
361
+ * `@/components/ui` convention, and a relative path that lands in one of the ui alias dirs.
362
+ */
363
+ function importsIntoUi(specDir, { file, uiAliases, uiDirs }) {
364
+ if (uiAliases.includes(specDir)) return true;
365
+ if (specDir === "." || specDir === ".." || /^\.\.?\//.test(specDir)) {
366
+ const target = resolve(dirname(file), specDir);
367
+ return uiDirs.includes(target);
368
+ }
369
+ return false;
370
+ }
371
+
372
+ /**
373
+ * Blank what is not class or CSS usage, keeping every newline so line numbers survive: `//` and
374
+ * `/* *\/` comments, and — in script files — a string literal that reads as prose ("Use text-strong
375
+ * for emphasis"). A class string is lower-case utility tokens; a capitalised word or sentence
376
+ * punctuation marks a sentence (never `!`, which is Tailwind's important modifier: `hidden!`).
377
+ *
378
+ * In a script file, JSX TEXT is not code, so nothing in it may open a comment or a string: an
379
+ * apostrophe ("Don't"), a URL's `//`, a glob's `/*`. Without a parser, position decides — a string
380
+ * opens only where an expression can begin (after `=`, `(`, `,`, `[`, `{`, `:`, `?`, an operator,
381
+ * `=>`, or a keyword such as `return`), and a comment only after whitespace or code punctuation,
382
+ * never after `:` (`https://`) or a word (`src/*.ts`).
383
+ */
384
+ const EXPRESSION_KEYWORDS = new Set([
385
+ "return",
386
+ "case",
387
+ "typeof",
388
+ "in",
389
+ "of",
390
+ "yield",
391
+ "await",
392
+ "void",
393
+ "delete",
394
+ "throw",
395
+ "from",
396
+ "import",
397
+ "export",
398
+ "default",
399
+ "else",
400
+ "do",
401
+ ]);
402
+ function canStartExpression(src, i) {
403
+ let k = i - 1;
404
+ while (k >= 0 && /\s/.test(src[k])) k -= 1;
405
+ if (k < 0) return true;
406
+ const p = src[k];
407
+ if (p === ">") return src[k - 1] === "="; // `=>`, never a JSX tag's `>`
408
+ if ("=(,[{:?&|!+-*%^~<;".includes(p)) return true;
409
+ if (/[\w$]/.test(p)) {
410
+ let w = k;
411
+ while (w >= 0 && /[\w$]/.test(src[w])) w -= 1;
412
+ return EXPRESSION_KEYWORDS.has(src.slice(w + 1, k + 1));
413
+ }
414
+ return false;
415
+ }
416
+ const canStartComment = (src, i) => i === 0 || /[\s;,{}()[\]]/.test(src[i - 1]);
417
+
418
+ export function maskNonUsage(
419
+ src,
420
+ { script = true, lineComments = script } = {},
421
+ ) {
422
+ const out = src.split("");
423
+ const blank = (from, to) => {
424
+ for (let k = from; k < to; k += 1) if (out[k] !== "\n") out[k] = " ";
425
+ };
426
+ const isProse = (text) =>
427
+ /(?:^|\s)[A-Z][a-z]+(?=[\s,.;:!?]|$)|[a-z][.,;:?](?:\s|$)/.test(
428
+ text.replace(/\[[^\]]*\]|\([^)]*\)/g, ""),
429
+ );
430
+ let i = 0;
431
+ while (i < src.length) {
432
+ const c = src[i];
433
+ const next = src[i + 1];
434
+ if (c === "/" && next === "*" && (!script || canStartComment(src, i))) {
435
+ const end = src.indexOf("*/", i + 2);
436
+ const stop = end === -1 ? src.length : end + 2;
437
+ blank(i, stop);
438
+ i = stop;
439
+ } else if (
440
+ c === "/" &&
441
+ next === "/" &&
442
+ lineComments &&
443
+ (script ? canStartComment(src, i) : i === 0 || /\s/.test(src[i - 1]))
444
+ ) {
445
+ const end = src.indexOf("\n", i);
446
+ const stop = end === -1 ? src.length : end;
447
+ blank(i, stop);
448
+ i = stop;
449
+ } else if (
450
+ (c === '"' || c === "'" || c === "`") &&
451
+ (!script || canStartExpression(src, i))
452
+ ) {
453
+ let j = i + 1;
454
+ while (j < src.length && src[j] !== c) {
455
+ if (src[j] === "\\") j += 1;
456
+ else if (src[j] === "\n" && c !== "`") break;
457
+ j += 1;
458
+ }
459
+ if (script && isProse(src.slice(i + 1, j))) blank(i + 1, j);
460
+ i = j + 1;
461
+ } else {
462
+ i += 1;
463
+ }
464
+ }
465
+ return out.join("");
466
+ }
467
+
468
+ /**
469
+ * Class names a stylesheet DEFINES — `@utility text-h2 { … }` (a `-*` functional utility as a
470
+ * prefix) and plain class selectors such as `.text-strong { … }`. A retired name the project defines
471
+ * itself compiles, so it is the project's, not a silent no-op.
472
+ */
473
+ function definedClasses(css) {
474
+ const exact = new Set();
475
+ const prefixes = [];
476
+ for (const m of css.matchAll(/([^{};]+)\{/g)) {
477
+ const prelude = m[1].trim();
478
+ const utility = prelude.match(/^@utility\s+([\w-]+?)(-\*)?$/);
479
+ if (utility) {
480
+ if (utility[2]) prefixes.push(`${utility[1]}-`);
481
+ else exact.add(utility[1]);
482
+ } else if (!prelude.startsWith("@")) {
483
+ for (const c of prelude.matchAll(/\.((?:\\.|[\w-])+)/g))
484
+ exact.add(c[1].replace(/\\(.)/g, "$1"));
485
+ }
486
+ }
487
+ return { exact, prefixes };
488
+ }
489
+
490
+ /** Every retired-vocabulary occurrence under `root`, skipping build output and the ui alias dirs. */
491
+ export function scanRetiredVocabulary(
492
+ root,
493
+ { skipDirs = [], uiAlias = null } = {},
494
+ ) {
495
+ const skip = new Set(skipDirs.map((d) => resolve(d)));
496
+ const uiAliases = [
497
+ ...new Set(
498
+ [uiAlias, "@/components/ui"]
499
+ .filter((a) => typeof a === "string" && a.length > 0)
500
+ .map((a) => a.replace(/\/+$/, "")),
501
+ ),
502
+ ];
503
+ const uiDirs = [...skip];
504
+ const files = [];
505
+ const walk = (dir) => {
506
+ if (files.length >= SCAN_MAX_FILES) return;
507
+ let entries;
508
+ try {
509
+ entries = readdirSync(dir, { withFileTypes: true });
510
+ } catch {
511
+ return;
512
+ }
513
+ for (const e of entries) {
514
+ if (e.name.startsWith(".") || SCAN_SKIP_DIRS.has(e.name)) continue;
515
+ const full = join(dir, e.name);
516
+ if (e.isDirectory()) {
517
+ if (!skip.has(resolve(full))) walk(full);
518
+ } else if (e.isFile() && SCAN_EXTENSIONS.test(e.name)) {
519
+ files.push(full);
520
+ if (files.length >= SCAN_MAX_FILES) return;
521
+ }
522
+ }
523
+ };
524
+ walk(root);
525
+
526
+ const sources = [];
527
+ const declared = new Set();
528
+ const declaredClasses = new Set();
529
+ const classPrefixes = [];
530
+ for (const file of files) {
531
+ try {
532
+ if (statSync(file).size > SCAN_MAX_BYTES) continue;
533
+ } catch {
534
+ continue;
535
+ }
536
+ const raw = readIfExists(file);
537
+ if (raw == null) continue;
538
+ const css = /\.s?css$/.test(file);
539
+ const src = maskNonUsage(raw, {
540
+ script: !css,
541
+ lineComments: !css || file.endsWith(".scss"),
542
+ });
543
+ sources.push({ file, src });
544
+ for (const m of src.matchAll(/(?<![\w-])(--[\w-]+)\s*:/g))
545
+ declared.add(m[1]);
546
+ if (css) {
547
+ const defined = definedClasses(src);
548
+ for (const name of defined.exact) declaredClasses.add(name);
549
+ classPrefixes.push(...defined.prefixes);
550
+ }
551
+ }
552
+ const definesClass = (name) =>
553
+ declaredClasses.has(name) ||
554
+ classPrefixes.some((prefix) => name.startsWith(prefix));
555
+
556
+ const findings = [];
557
+ for (const { file, src } of sources) {
558
+ const lines = src.split("\n");
559
+ for (let i = 0; i < lines.length; i += 1) {
560
+ const line = lines[i];
561
+ if (line.length > 4000) continue; // minified output that slipped past the dir skips
562
+ for (const rule of RETIRED_VOCABULARY) {
563
+ rule.re.lastIndex = 0;
564
+ for (const m of line.matchAll(rule.re)) {
565
+ const groups = m.slice(1);
566
+ if (rule.accept && !rule.accept(m, { file, uiAliases, uiDirs }))
567
+ continue;
568
+ const own = [rule.declared(m[0], ...groups)].flat().filter(Boolean);
569
+ if (own.some((name) => declared.has(name))) continue;
570
+ if (!m[0].startsWith("--") && definesClass(m[0])) continue;
571
+ findings.push({
572
+ file: relative(root, file).split(sep).join("/"),
573
+ line: i + 1,
574
+ match: rule.label ? rule.label(m[0], ...groups) : m[0],
575
+ hint: rule.hint(m[0], ...groups),
576
+ });
577
+ }
578
+ }
579
+ }
580
+ }
581
+ return {
582
+ files: sources.length,
583
+ findings,
584
+ truncated: files.length >= SCAN_MAX_FILES,
585
+ };
586
+ }
587
+
117
588
  export function main(argv = []) {
118
589
  if (argv.includes("-h") || argv.includes("--help")) {
119
590
  console.log(USAGE);
@@ -304,6 +775,34 @@ export function main(argv = []) {
304
775
  }
305
776
  }
306
777
 
778
+ // ---- 7. no retired vocabulary in the project's own source --------------------------------
779
+ const skipDirs = componentsJson
780
+ ? uiAliasDirs(componentsJson, dirname(componentsJsonPath))
781
+ : uiAliasDirs(null, root);
782
+ const scan = scanRetiredVocabulary(root, {
783
+ skipDirs,
784
+ uiAlias: componentsJson?.aliases?.ui ?? null,
785
+ });
786
+ const scanned = `${scan.files} source file(s)${
787
+ skipDirs.length
788
+ ? `, skipping ${skipDirs.map((d) => relative(root, d) || ".").join(", ")}`
789
+ : ""
790
+ }${scan.truncated ? ` (stopped at ${SCAN_MAX_FILES} files)` : ""}`;
791
+ if (scan.findings.length === 0) {
792
+ ok("no retired vocabulary", scanned);
793
+ } else {
794
+ const inFiles = new Set(scan.findings.map((f) => f.file)).size;
795
+ results.push({
796
+ level: "fail",
797
+ name: "no retired vocabulary",
798
+ detail: `${scan.findings.length} occurrence(s) in ${inFiles} file(s) of ${scanned} — these compile to nothing, silently`,
799
+ fix: `rewrite each as its hint says; every row is in ${GUIDE}`,
800
+ findings: scan.findings.map(
801
+ (f) => `${f.file}:${f.line} ${f.match} → ${f.hint}`,
802
+ ),
803
+ });
804
+ }
805
+
307
806
  // ---- report -------------------------------------------------------------------------------
308
807
  const glyph = { ok: "✓", warn: "!", fail: "✗" };
309
808
  console.log("");
@@ -311,6 +810,7 @@ export function main(argv = []) {
311
810
  console.log(
312
811
  ` ${glyph[r.level]} ${r.name}${r.detail ? ` — ${r.detail}` : ""}`,
313
812
  );
813
+ for (const finding of r.findings ?? []) console.log(` ${finding}`);
314
814
  if (r.fix) console.log(` fix: ${r.fix}`);
315
815
  }
316
816
 
@@ -5,7 +5,8 @@
5
5
  // check-updates Show which copied-in components have newer registry versions (what to re-pull).
6
6
  // verify Verify a registry item's integrity before/after `shadcn add` (Sigstore + hash).
7
7
  // skills Install the bundled VegaStack agent skills into the consuming project.
8
- // doctor Check a consuming project's setup (PostCSS plugin, preset import, registry).
8
+ // doctor Check a consuming project's setup (PostCSS plugin, preset import, registry,
9
+ // retired design-system vocabulary in its own source).
9
10
  //
10
11
  // The bin is named `vegastack-design` (NOT `vegastack`) so it never collides with a platform CLI.
11
12
  // `check-updates` is imported in-process; `verify` is spawned (it's the standalone, hash-parity-tested
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vegastack/design",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "VegaStack design system — cn utility, icon runtime, Tailwind v4 preset, and the vegastack-design CLI (tokens ship separately as @vegastack/design-tokens)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -67,10 +67,10 @@
67
67
  "lint": "eslint . && node ../../tooling/design-lint.mjs src && pnpm run verify",
68
68
  "verify": "node ../../tooling/verify-preset-source.mjs",
69
69
  "typecheck": "tsc --noEmit",
70
- "test": "node test/compare.test.mjs && node test/check-updates.test.mjs && node test/skills-install.test.mjs"
70
+ "test": "node test/compare.test.mjs && node test/check-updates.test.mjs && node test/skills-install.test.mjs && node test/doctor.test.mjs"
71
71
  },
72
72
  "dependencies": {
73
- "@vegastack/design-tokens": "^0.7.0",
73
+ "@vegastack/design-tokens": "^0.7.1",
74
74
  "clsx": "^2.1.1",
75
75
  "tailwind-merge": "^3.6.0",
76
76
  "tsconfig-paths": "^4.2.0",
package/preset.css CHANGED
@@ -13,15 +13,16 @@
13
13
  *
14
14
  * This package's own `dist` ships the icon runtime (`BrandIcon`'s
15
15
  * `inline-flex shrink-0 [&>svg]:size-full`), and `@vegastack/ui` ships a compiled
16
- * provider + `Toaster` whose Base UI Toast classes (e.g. `bg-popover shadow-overlay`)
16
+ * provider + `Toaster` whose Base UI Toast classes (e.g. `bg-popover shadow-lg`)
17
17
  * live in its published `dist/*.js`, NOT in the consumer's own source — so without
18
18
  * an explicit `@source` a real npm consumer importing ONLY this preset would get
19
19
  * a partially-unstyled provider/Toaster + icon UI.
20
20
  *
21
21
  * Why `./dist` + a RELATIVE `../ui/dist` path and NOT `@source "@vegastack/ui"`:
22
- * - Tailwind v4.3.1 (the pinned version) does NOT resolve a bare package-name
23
- * `@source` through node_modules — it treats `@vegastack/ui` as a literal
24
- * glob and silently scans nothing (verified: tooling/verify-preset-source.mjs).
22
+ * - Tailwind v4 (whatever version the lockfile pins) does NOT resolve a bare
23
+ * package-name `@source` through node_modules — it treats `@vegastack/ui` as a
24
+ * literal glob and silently scans nothing (re-verified against the installed
25
+ * version by tooling/verify-preset-source.mjs).
25
26
  * - Tailwind resolves `@source` relative to this file's realpath. The icon runtime
26
27
  * is INSIDE this package now (`./dist`). `@vegastack/ui` — when present — sits as
27
28
  * a sibling under the same `@vegastack` scope in node_modules, making `../ui`
@@ -17,13 +17,20 @@ These are mechanical and catch the highest-value problems:
17
17
 
18
18
  ```bash
19
19
  npx --package=@vegastack/design vegastack-design check-updates
20
+ npx --package=@vegastack/design vegastack-design doctor
20
21
  ```
21
22
 
23
+ `doctor` checks setup and also scans the app's own source (skipping `node_modules`, build output
24
+ and the `components.json` `ui` directory) for vocabulary the shadcn reset retired — `text-h1`,
25
+ `text-label`, `bg-destructive-subtle`, `--z-toast`, an `icon-button` import — and exits non-zero
26
+ with `file:line` and the replacement for each. Its findings are §3's retired-vocabulary **errors**;
27
+ cite them rather than re-deriving them.
28
+
22
29
  `⬆ update` means the registry has a newer version. `≈ drift` means the installed file differs from
23
- the registry item — either an upstream change or a local edit to a file you do not own. Both are
24
- findings; a local edit to a copied-in component is a **high** finding, because the next
25
- `--overwrite` silently destroys it. The fix is to move the customisation into your own wrapper
26
- component or a token override.
30
+ the registry item — either an upstream change or a local edit. Both are findings. The rule for
31
+ edits is the one the Components guide states: **don't edit a copied-in component; if you must, it
32
+ becomes yours** — `check-updates` reports it as drifted from then on and every update is a `--diff`
33
+ re-applied by hand (§5).
27
34
 
28
35
  Then verify setup, since these failures look like component bugs:
29
36
 
@@ -139,9 +146,11 @@ rg -n 'surface-(1|2|3|raised)|--alpha-|--opacity-|--size-|--icon-|--panel-width-
139
146
  rg -n '@vegastack' components/ui/ -l
140
147
  ```
141
148
 
142
- A copied-in component is yours to keep but not to edit — the next `--overwrite` overwrites it. Any
143
- diff reported by `check-updates` as `≈ drift` on a file you did not intend to change is a **high**
144
- finding. Route customisation through a token override, a wrapper component, or a `className` prop.
149
+ Don't edit a copied-in component; if you must, it becomes yours. Report every `≈ drift` as a
150
+ **warning** — the file no longer receives registry fixes and the next `--overwrite` replaces the
151
+ edit — and name the way back: move the customisation into a token override, a wrapper component,
152
+ or a `className` at the call site, then re-pull. A drifted file whose edit nobody meant to make
153
+ (no commit or comment owns it) is an **error**: it is lost work waiting to happen.
145
154
 
146
155
  A missing `// @vegastack …` provenance header is **normal** and never a finding on its own: the
147
156
  shadcn CLI strips leading comments during copy-in.
@@ -150,9 +159,10 @@ shadcn CLI strips leading comments during copy-in.
150
159
 
151
160
  Group by file. Each finding: `file:line` · rule · suggested fix · severity.
152
161
 
153
- - **error** — a hardcoded visual value, an accessibility violation, or an edited copied-in component.
162
+ - **error** — a hardcoded visual value, an accessibility violation, retired vocabulary, or an
163
+ unintended edit to a copied-in component.
154
164
  - **warning** — raw HTML where a component exists, a missing state, an off-system utility with a
155
- working fallback.
165
+ working fallback, a deliberately edited (now owned) copied-in component.
156
166
  - **info** — a component with an available update worth a deliberate `--diff` review.
157
167
 
158
168
  Never auto-fix. Report, and let the owner decide.
@@ -209,7 +209,29 @@ contract.
209
209
  - Hand-roll a removable pill, or a `role="status"` live region with its own sequence counter.
210
210
  - Give a form control a fixed width (`w-56`, `w-64`) — it reads fine on the page it was tuned for
211
211
  and overflows at 320px. Constrain the parent instead.
212
+ - Write an arbitrary text size (`text-[13px]`, `text-[0.8rem]`, `text-[2rem]/9`). It bypasses the
213
+ `--text-*` namespace, so it receives neither the ramp's line-height nor its letter-spacing, and
214
+ lint rejects it. Take the nearest ramp step. Likewise no local `tracking-*` and no `uppercase`.
215
+ - Draw a surface edge with a ring (`ring-1 ring-foreground/10`). Cards and floating surfaces use
216
+ `border border-border` (BRD-1); lint rejects the ring.
212
217
  - Expect a compatibility shim from before the reset. There is none — see the migration guide.
218
+ - Write retired vocabulary. It compiles to **nothing** — no build error, no type error — so a heading
219
+ silently renders as body text. `vegastack-design doctor` scans your source and lists every
220
+ occurrence with `file:line`; run it after any upgrade or generated change.
221
+
222
+ | Retired | Write instead |
223
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------- |
224
+ | `text-h1` · `text-h2` · `text-h3` · `text-h4` | `text-3xl` · `text-2xl` · `text-lg` + `font-semibold`; `text-base font-medium` |
225
+ | `text-label` · `text-label-sm` · `text-strong` | `text-sm font-medium` · `text-xs font-medium` · `text-sm font-semibold` |
226
+ | `text-mono-label` · `text-code` · `text-code-sm` | `text-xs font-medium` (a label is sans) · `font-mono text-sm` · `font-mono text-xs` |
227
+ | `text-display-{sm,md,lg,xl}` | `text-4xl` · `text-5xl` · `text-6xl` · `text-7xl` |
228
+ | `bg-{destructive,success,warning,info}-subtle` | `bg-destructive/10` (the family at `/10`), `-text` ink on it |
229
+ | `--alpha-*` · `--opacity-*` | the literal: `bg-foreground/10`, `opacity-50` |
230
+ | `--z-*` | `z-10` (raised) · `z-50` (every overlay; DOM order decides) |
231
+ | `shadow-overlay` · `backdrop-blur-glass` | `shadow-md` (popover) / `shadow-lg` (modal) · delete it |
232
+ | `icon-button` · `segmented` · `password-input` | `Button size="icon"` + `aria-label` · joined `ToggleGroup` · `InputGroup` composition |
233
+ | `progress-indicator` · `field-inline` · `floating-surface` | `Progress` / `Spinner` · `EditableCell` · `Popover` |
234
+ | `section-header` · `sonner` | your own heading markup · `toast` (`toast.add({ title })`) |
213
235
 
214
236
  ## Reference
215
237
 
@@ -3,7 +3,7 @@
3
3
  <!-- GENERATED — do not hand-edit. Regenerated from the design system's component contract,
4
4
  which is the authority for membership and counts. -->
5
5
 
6
- **110 components**, plus 467 animated-icon items, 11 hooks (`use-animation-replay`, `use-announcer`, `use-drag-reorder`, `use-file-drop`, `use-inline-edit`, `use-list-nav`, `use-media-query`, `use-mobile`, `use-modal-inert`, `use-overflow`, `use-platform`), 32 starter blocks (`app-shell-01`, `board-01`, `dashboard-01`, `login-01`, `login-02`, `login-03`, `login-04`, `login-05`, `onboarding-01`, `preview-03`, `settings-01`, `sidebar-01`, `sidebar-02`, `sidebar-03`, `sidebar-04`, `sidebar-05`, `sidebar-06`, `sidebar-07`, `sidebar-08`, `sidebar-09`, `sidebar-10`, `sidebar-11`, `sidebar-12`, `sidebar-13`, `sidebar-14`, `sidebar-15`, `sidebar-16`, `signup-01`, `signup-02`, `signup-03`, `signup-04`, `signup-05`), 68 chart blocks across 7 families, and 2 data libs (`geo-data`, `drag-item`) — 690 registry items in total.
6
+ **111 components**, plus 467 animated-icon items, 11 hooks (`use-animation-replay`, `use-announcer`, `use-drag-reorder`, `use-file-drop`, `use-inline-edit`, `use-list-nav`, `use-media-query`, `use-mobile`, `use-modal-inert`, `use-overflow`, `use-platform`), 32 starter blocks (`app-shell-01`, `board-01`, `dashboard-01`, `login-01`, `login-02`, `login-03`, `login-04`, `login-05`, `onboarding-01`, `preview-03`, `settings-01`, `sidebar-01`, `sidebar-02`, `sidebar-03`, `sidebar-04`, `sidebar-05`, `sidebar-06`, `sidebar-07`, `sidebar-08`, `sidebar-09`, `sidebar-10`, `sidebar-11`, `sidebar-12`, `sidebar-13`, `sidebar-14`, `sidebar-15`, `sidebar-16`, `signup-01`, `signup-02`, `signup-03`, `signup-04`, `signup-05`), 68 chart blocks across 7 families, and 2 data libs (`geo-data`, `drag-item`) — 691 registry items in total.
7
7
 
8
8
  Install any of them with `shadcn add @vegastack/<name>`. Animated icons install as
9
9
  `@vegastack/icon-<name>`; the bare name is reserved for components, so a component whose name
@@ -77,6 +77,7 @@ starts with `icon-` is a component and never an icon.
77
77
 
78
78
  - **`data-grid`** — The full-parity grid — TanStack-sorted multi-key sort, column picker with responsive revelation, collapsible grouping, keyboard-continuous load-more, opt-in virtualization, and an APG grid keyboard layer with inline cell editing.
79
79
  - **`data-list`** — A generic, typed data table — configurable columns, row selection, sortable headers, plus loading and empty states.
80
+ - **`data-list-pager`** — A controlled paging footer for DataList — a tabular-numeral range summary, a rows-per-page Select, and a windowed Pagination that hides on a single page.
80
81
  - **`data-table-parts`** — The chrome DataList and DataGrid share — sort header, selection cells, skeleton rows, the empty row, column class rules, and the selection/sort/controlled-state hooks.
81
82
  - **`filter-bar`** — A row of removable filter chips, an "Add filter" dropdown, and an optional search input — for list and table filter toolbars.
82
83
  - **`filter-bar-managed`** — The stateful nested and/or filter builder — host-injected field grammar (vocabulary + per-type value editors), depth and condition caps, focus-managed removal, and a removable FilterChip summary.