basalt-ui 1.20.0 → 1.22.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 (89) hide show
  1. package/AGENTS.md +11 -5
  2. package/CHANGELOG.md +33 -0
  3. package/MIGRATING.md +231 -50
  4. package/README.md +57 -10
  5. package/agent/rules/basalt-app.md +49 -2
  6. package/agent/rules/basalt-charts.md +15 -12
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-router.md +57 -5
  9. package/agent/rules/basalt-state.md +21 -3
  10. package/agent/rules/basalt-tokens.md +78 -16
  11. package/agent/skills/basalt-app/SKILL.md +5 -1
  12. package/agent/skills/basalt-charts/SKILL.md +5 -3
  13. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  14. package/configs/lefthook.yml +48 -15
  15. package/configs/oxlint-plugin.js +218 -69
  16. package/configs/oxlint-plugin.test.ts +331 -9
  17. package/dist/charts/kinds/Bars.d.ts +1 -1
  18. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  19. package/dist/charts/kinds/Donut.d.ts +1 -1
  20. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  21. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  22. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  23. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  24. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  25. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  26. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  27. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  28. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  29. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  30. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  31. package/dist/cli/index.d.ts +55 -15
  32. package/dist/cli/index.d.ts.map +1 -1
  33. package/dist/cli/index.js +521 -71
  34. package/dist/cli/waiver-audit.test.d.ts +2 -0
  35. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  36. package/dist/guard/index.d.ts +114 -2
  37. package/dist/guard/index.d.ts.map +1 -1
  38. package/dist/guard/index.js +216 -33
  39. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  40. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  41. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  42. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  43. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  44. package/dist/router-tanstack/search-param-store.js +31 -3
  45. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  46. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  47. package/dist/shell/index.d.ts +12 -5
  48. package/dist/shell/index.d.ts.map +1 -1
  49. package/dist/shell/index.js +24 -6
  50. package/dist/tokens/index.d.ts +3 -1
  51. package/dist/tokens/index.d.ts.map +1 -1
  52. package/dist/tokens/index.js +5 -1
  53. package/dist/tokens/palette.d.ts +2 -2
  54. package/dist/tokens/palette.js +2 -2
  55. package/dist/tokens/text-scale.test.d.ts +2 -0
  56. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  57. package/dist/tokens.css +4 -2
  58. package/dist/vite.d.ts +28 -3
  59. package/dist/vite.d.ts.map +1 -1
  60. package/dist/vite.js +75 -26
  61. package/llms.txt +2 -2
  62. package/package.json +1 -1
  63. package/src/charts/kinds/Bars.tsx +1 -1
  64. package/src/charts/kinds/Donut.tsx +1 -1
  65. package/src/charts/kinds/DualPanel.tsx +2 -2
  66. package/src/charts/kinds/Heatmap.tsx +1 -1
  67. package/src/charts/kinds/MultiLine.tsx +1 -1
  68. package/src/charts/kinds/StackedArea.tsx +1 -1
  69. package/src/charts/kinds/ZonedLine.tsx +1 -1
  70. package/src/cli/check-theme.test.ts +35 -0
  71. package/src/cli/help.test.ts +5 -1
  72. package/src/cli/index.ts +978 -133
  73. package/src/cli/init-sync.test.ts +2 -0
  74. package/src/cli/template-ledger.test.ts +1 -1
  75. package/src/cli/toolchain-wiring.test.ts +352 -1
  76. package/src/cli/waiver-audit.test.ts +211 -0
  77. package/src/guard/check-source.test.ts +638 -1
  78. package/src/guard/index.ts +556 -63
  79. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  80. package/src/router-tanstack/search-param-store.test.ts +135 -0
  81. package/src/router-tanstack/search-param-store.ts +90 -8
  82. package/src/shell/app-mobile-nav.tsx +1 -1
  83. package/src/shell/index.test.tsx +52 -0
  84. package/src/shell/index.tsx +64 -13
  85. package/src/tokens/index.ts +24 -1
  86. package/src/tokens/palette.ts +2 -2
  87. package/src/tokens/text-scale.test.ts +51 -0
  88. package/src/vite.test.ts +158 -6
  89. package/src/vite.ts +176 -34
package/dist/cli/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { createHash } from "node:crypto";
2
3
  import {
3
4
  existsSync,
@@ -14,12 +15,33 @@ import {
14
15
  checkSource,
15
16
  DEFAULT_GUARD_CONFIG,
16
17
  GENERATED_HEADER_LINE,
18
+ findAllowAnnotations,
19
+ guardKindRemedy,
17
20
  GUARD_RULES,
18
- TOKENS_ONLY_DISABLED_KINDS
21
+ guardWaiverHint,
22
+ neutralizeAllowAnnotation,
23
+ TOKENS_ONLY_DISABLED_KINDS,
24
+ unmatchedExemptPatterns
19
25
  } from "../guard/index.js";
20
26
  import { evaluateGuardHook } from "../guard/guard-hook.js";
21
27
  import { RULE_NAMES, SKILL_NAMES, SURFACES, TOKEN_LAYER_BOUNDARY_SURFACES } from "../surfaces.js";
22
28
  import { buildPaletteCss, deriveSpacing } from "../tokens/index.js";
29
+ function exemptRulePaths(entry) {
30
+ if (entry === void 0) return [];
31
+ return Array.isArray(entry) ? entry : entry.paths;
32
+ }
33
+ function exemptRuleReason(entry) {
34
+ if (entry === void 0 || Array.isArray(entry)) return null;
35
+ return entry.reason.trim().length > 0 ? entry.reason.trim() : null;
36
+ }
37
+ function resolveExemptRules(cfg) {
38
+ if (cfg.exemptRules === void 0) return DEFAULT_GUARD_CONFIG.exemptRules;
39
+ const out = {};
40
+ for (const [kind, entry] of Object.entries(cfg.exemptRules)) {
41
+ out[kind] = exemptRulePaths(entry);
42
+ }
43
+ return out;
44
+ }
23
45
  const DEFAULT_ROOT = "src";
24
46
  const DEFAULT_ROOTS = [DEFAULT_ROOT];
25
47
  function resolveRoots(cfg) {
@@ -89,25 +111,6 @@ function walkSourceFiles(dir, out = []) {
89
111
  }
90
112
  return out;
91
113
  }
92
- const GUARD_KIND_EXPLANATIONS = {
93
- "raw-hex": "raw-hex: route color through VX.* / the Mantine theme instead of a hardcoded hex literal.",
94
- "raw-color-fn": "raw-color-fn: route color through VX.* / the Mantine theme instead of a hardcoded rgb()/hsl() literal.",
95
- "off-identity-accent": "off-identity-accent: use blue/gray or a status hue (red/green/orange/yellow) instead.",
96
- "raw-spacing": "raw-spacing: use the spacing scale token instead of a raw literal.",
97
- "raw-radius": "raw-radius: use the radius scale token instead of a raw literal.",
98
- "raw-surface": "raw-surface: use `VX.surface.*` / a `radius` token / `var(--vx-radius-card)` instead of an inline border/radius/shadow.",
99
- "card-with-border": "card-with-border: `withBorder` on a Card/Paper double-draws the edge \u2014 card depth is `--vx-shadow-card`, which already bakes a 1px ring into the shadow. Drop the prop.",
100
- "off-system-surface-var": "off-system-surface-var: raw Mantine ramp step bypasses the basalt surface tokens \u2014 use VX.surface.* / --vx-surface-* (border/panel/subtle/bg) instead.",
101
- "raw-html-layout": "raw-html-layout: raw HTML element with inline layout/surface styling \u2014 use a Mantine layout primitive (Box/Flex/Grid/Stack/Group).",
102
- "inline-spacing": "inline-spacing: inline spacing literal \u2014 use the Mantine spacing prop/token (p/m/gap with xs..xl).",
103
- "inline-display": "inline-display: use <Flex>/<Grid>/<Group> instead of an inline display:flex/grid.",
104
- "raw-visx-axis": "raw-visx-axis: raw <AxisLeft>/<AxisBottom>/<AxisRight> in a chart file \u2014 use AxisLeftNumeric / AxisBottomDate / AxisRightNumeric from the charts primitives.",
105
- "raw-motion-value": "raw-motion-value: hardcoded duration/spring/ease in `transition={{...}}` \u2014 use MOTION_DURATION / MOTION_SPRING / MOTION_EASE_STANDARD instead.",
106
- "unframed-chart": "unframed-chart: hand-rolled ChartLegend built from an inline array literal \u2014 pass a derived legend (deriveLegend(series)), or compose ChartFrame, which derives it for you.",
107
- "raw-form-control": "raw-form-control: raw <input>/<select>/<textarea> bypasses the entire theme \u2014 use TextInput / NumberInput / Select / Textarea from @mantine/core.",
108
- "sub-16-input-font": "sub-16-input-font: fontSize below 16 on a form control is dead code against the `!important` iOS floor in styles.css \u2014 drop the override or be honest about 16px.",
109
- "raw-font-family": "raw-font-family: route the font stack through createBasaltTheme(overrides, { fonts }) / the --basalt-font-sans|head|mono vars instead of a hardcoded fontFamily literal."
110
- };
111
114
  function relativePosix(fromDir, toPath) {
112
115
  const rel = relative(fromDir, toPath).replace(/\\/g, "/");
113
116
  if (rel === "") return ".";
@@ -219,6 +222,13 @@ function appShellFiles(rootAbs) {
219
222
  out.push(...walkSourceFiles(resolve(appDir, "public")));
220
223
  return out;
221
224
  }
225
+ const PLAIN_JSON_HINT_PATH = "manifest.json";
226
+ function waiverHintFor(relPath, profile) {
227
+ if (profile !== "tokens-only" || !relPath.endsWith(".webmanifest")) {
228
+ return guardWaiverHint(relPath);
229
+ }
230
+ return `${guardWaiverHint(PLAIN_JSON_HINT_PATH)} (basaltAppPlugin would emit the file instead, but it needs basalt's Vite/React layer, which a tokens-only consumer has opted out of.)`;
231
+ }
222
232
  function checkTheme(invocationCwd = process.cwd(), flags = []) {
223
233
  if (conflictingProfileFlags(flags)) {
224
234
  console.error(
@@ -265,7 +275,7 @@ function checkTheme(invocationCwd = process.cwd(), flags = []) {
265
275
  rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
266
276
  sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
267
277
  allowComment: "theme-allow",
268
- exemptRules: cfg.exemptRules ?? DEFAULT_GUARD_CONFIG.exemptRules,
278
+ exemptRules: resolveExemptRules(cfg),
269
279
  severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
270
280
  ...profile === "tokens-only" ? { profile: "tokens-only" } : {}
271
281
  };
@@ -286,6 +296,12 @@ function checkTheme(invocationCwd = process.cwd(), flags = []) {
286
296
  }
287
297
  return 1;
288
298
  }
299
+ if (flags.includes("--audit-allows")) return auditAllows(cwd, cfg, guardCfg, scanned);
300
+ for (const unmatched of unmatchedExemptPatterns(guardCfg, scanned)) {
301
+ console.error(
302
+ `\u26A0 basalt-ui check-theme: "basalt.exemptRules" entry ${unmatched.kind}: "${unmatched.pattern}" ` + (unmatched.reason === "unknown-kind" ? "names no guard kind \u2014 it exempts nothing at all (a typo, or a kind that was renamed)." : "matched none of the scanned files \u2014 it suppresses nothing, and reads as coverage in a config review while enforcing as much as an empty object.")
303
+ );
304
+ }
289
305
  if (findings.length === 0) {
290
306
  console.log("\u2713 Theme guard: no off-palette colors.");
291
307
  return 0;
@@ -322,14 +338,11 @@ function checkTheme(invocationCwd = process.cwd(), flags = []) {
322
338
  report(errors, `\u2716 Theme guard: ${errors.length} off-palette / off-identity violation(s)
323
339
  `);
324
340
  }
325
- const presentKinds = new Set(findings.map((f) => f.kind));
326
- const explanations = Object.keys(GUARD_KIND_EXPLANATIONS).filter((kind) => presentKinds.has(kind)).map((kind) => GUARD_KIND_EXPLANATIONS[kind]);
327
- const fixLine = [
328
- "Fix:",
329
- ...explanations,
330
- "Add a `theme-allow` comment for a deliberate exception."
331
- ];
332
- console.error(fixLine.join(" "));
341
+ const presentKinds = [...new Set(findings.map((f) => f.kind))].toSorted();
342
+ const waiverHints = [
343
+ ...new Set(findings.map((f) => waiverHintFor(f.relPath, profile)))
344
+ ].toSorted();
345
+ console.error(["Fix:", ...presentKinds.map(guardKindRemedy), ...waiverHints].join(" "));
333
346
  if (findings.some((f) => SERIES_MODULE_HINT_RE.test(f.relPath))) {
334
347
  console.error(
335
348
  'Hint: if a flagged file IS your palette/series source, exempt it via "basalt": { "exempt": ["<path>"] } in package.json.'
@@ -337,6 +350,232 @@ function checkTheme(invocationCwd = process.cwd(), flags = []) {
337
350
  }
338
351
  return errors.length > 0 ? 1 : 0;
339
352
  }
353
+ function findingKey(f) {
354
+ return `${f.relPath}:${f.line}:${f.kind}`;
355
+ }
356
+ function resolveToolBin(dir, name) {
357
+ let current = dir;
358
+ for (; ; ) {
359
+ const candidate = resolve(current, "node_modules/.bin", name);
360
+ if (existsSync(candidate)) return candidate;
361
+ const parent = resolve(current, "..");
362
+ if (parent === current) break;
363
+ current = parent;
364
+ }
365
+ const probe = spawnSync(name, ["--version"], { stdio: "ignore", timeout: 1e4 });
366
+ return probe.error === void 0 && probe.status === 0 ? name : null;
367
+ }
368
+ function diagnosticCounts(diagnostics, filename) {
369
+ const counts = /* @__PURE__ */ new Map();
370
+ for (const d of diagnostics) {
371
+ if (d.filename !== filename) continue;
372
+ const code = typeof d.code === "string" ? d.code : "";
373
+ const rule = /^basalt\((.+)\)$/.exec(code)?.[1];
374
+ if (rule === void 0) continue;
375
+ const line = d.labels?.[0]?.span?.line;
376
+ const key = `${typeof line === "number" ? line : 0}:${rule}`;
377
+ counts.set(key, (counts.get(key) ?? 0) + 1);
378
+ }
379
+ return counts;
380
+ }
381
+ function probePluginRules(cwd, guardCfg, probes, sources) {
382
+ if (probes.length === 0) return /* @__PURE__ */ new Map();
383
+ const bin = resolveToolBin(cwd, "oxlint");
384
+ if (bin === null) return null;
385
+ const written = [];
386
+ const probePaths = /* @__PURE__ */ new Map();
387
+ try {
388
+ for (const probe of probes) {
389
+ const probed = neutralizeAllowAnnotation(sources.get(probe.rel) ?? "", probe.line, guardCfg);
390
+ const dot = probe.rel.lastIndexOf(".");
391
+ const rel = dot === -1 ? `${probe.rel}.basalt-audit-${probe.slot}` : `${probe.rel.slice(0, dot)}.basalt-audit-${probe.slot}${probe.rel.slice(dot)}`;
392
+ const abs = resolve(cwd, rel);
393
+ if (existsSync(abs)) continue;
394
+ writeFileSync(abs, probed, "utf8");
395
+ written.push(abs);
396
+ probePaths.set(probe.slot, rel);
397
+ }
398
+ if (probePaths.size === 0) return null;
399
+ const targets = [...new Set(probes.map((p) => p.rel)), ...probePaths.values()];
400
+ const run2 = spawnSync(bin, ["--format=json", "--no-error-on-unmatched-pattern", ...targets], {
401
+ cwd,
402
+ encoding: "utf8",
403
+ timeout: 12e4,
404
+ stdio: ["ignore", "pipe", "ignore"]
405
+ });
406
+ if (run2.error !== void 0 || typeof run2.stdout !== "string") return null;
407
+ let diagnostics;
408
+ try {
409
+ diagnostics = JSON.parse(run2.stdout).diagnostics ?? [];
410
+ } catch {
411
+ return null;
412
+ }
413
+ const verdicts = /* @__PURE__ */ new Map();
414
+ for (const probe of probes) {
415
+ const probeRel = probePaths.get(probe.slot);
416
+ if (probeRel === void 0) continue;
417
+ const before = diagnosticCounts(diagnostics, probe.rel);
418
+ const after = diagnosticCounts(diagnostics, probeRel);
419
+ const revealed = [];
420
+ for (const [key, count] of after) {
421
+ if (count <= (before.get(key) ?? 0)) continue;
422
+ const [line = "", rule = ""] = key.split(":");
423
+ revealed.push(`${rule}@${line}`);
424
+ }
425
+ verdicts.set(probe.slot, revealed.toSorted());
426
+ }
427
+ return verdicts;
428
+ } finally {
429
+ for (const abs of written) {
430
+ try {
431
+ unlinkSync(abs);
432
+ } catch {
433
+ }
434
+ }
435
+ }
436
+ }
437
+ function auditAllows(cwd, cfg, guardCfg, scanned) {
438
+ const token = guardCfg.allowComment;
439
+ const lines = [`
440
+ basalt-ui check-theme --audit-allows \u2014 ${cwd}
441
+ `];
442
+ let dead = 0;
443
+ let live = 0;
444
+ let outOfReach = 0;
445
+ let unaccountable = 0;
446
+ const waiverLines = [];
447
+ const pluginProbes = [];
448
+ const sources = /* @__PURE__ */ new Map();
449
+ for (const rel of scanned) sources.set(rel, readFileSync(resolve(cwd, rel), "utf8"));
450
+ for (const rel of scanned) {
451
+ const text = sources.get(rel) ?? "";
452
+ if (!text.includes(token)) continue;
453
+ const baseline = checkSource(text, rel, guardCfg);
454
+ const baselineKeys = new Set(
455
+ baseline.filter((f) => f.kind !== "theme-allow-unscoped").map(findingKey)
456
+ );
457
+ const unscopedAt = new Map(
458
+ baseline.filter((f) => f.kind === "theme-allow-unscoped").map((f) => [f.line, f.token])
459
+ );
460
+ for (const site of findAllowAnnotations(text, rel, guardCfg)) {
461
+ const revealed = checkSource(
462
+ neutralizeAllowAnnotation(text, site.line, guardCfg),
463
+ rel,
464
+ guardCfg
465
+ ).filter((f) => f.kind !== "theme-allow-unscoped" && !baselineKeys.has(findingKey(f))).map((f) => `${f.kind}@${f.line}`);
466
+ const note = unscopedAt.get(site.line);
467
+ if (note !== void 0) unaccountable++;
468
+ const label = ` ${rel}:${site.line}`.padEnd(52);
469
+ const suffix = note === void 0 ? "" : ` [${note}]`;
470
+ if (revealed.length > 0) {
471
+ live++;
472
+ waiverLines.push(`${label} suppresses ${[...new Set(revealed)].join(", ")}${suffix}`);
473
+ continue;
474
+ }
475
+ if (site.pluginRules.length > 0 && site.guardKinds.length === 0) {
476
+ pluginProbes.push({
477
+ slot: waiverLines.length,
478
+ rel,
479
+ line: site.line,
480
+ ids: site.pluginRules,
481
+ site: label,
482
+ suffix
483
+ });
484
+ waiverLines.push("");
485
+ continue;
486
+ }
487
+ if (site.guardKinds.length === 0 && site.unknownRules.length > 0) {
488
+ outOfReach++;
489
+ waiverLines.push(
490
+ `${label} scoped to ${site.unknownRules.join(", ")} \u2014 names no guard kind and no plugin rule, so this audit cannot judge it (most likely a typo, which theme-allow-unscoped reports)${suffix}`
491
+ );
492
+ continue;
493
+ }
494
+ dead++;
495
+ waiverLines.push(`${label} SUPPRESSES NOTHING \u2014 dead, delete it${suffix}`);
496
+ }
497
+ }
498
+ const pluginVerdicts = probePluginRules(cwd, guardCfg, pluginProbes, sources);
499
+ for (const probe of pluginProbes) {
500
+ const revealed = pluginVerdicts?.get(probe.slot);
501
+ if (revealed === void 0) {
502
+ outOfReach++;
503
+ waiverLines[probe.slot] = `${probe.site} scoped to ${probe.ids.join(", ")} \u2014 an oxlint plugin rule, and oxlint could not be run here, so this audit cannot judge it${probe.suffix}`;
504
+ continue;
505
+ }
506
+ if (revealed.length > 0) {
507
+ live++;
508
+ waiverLines[probe.slot] = `${probe.site} suppresses ${revealed.join(", ")} (oxlint)${probe.suffix}`;
509
+ continue;
510
+ }
511
+ dead++;
512
+ waiverLines[probe.slot] = `${probe.site} SUPPRESSES NOTHING \u2014 dead, delete it (proved by re-running oxlint over the file with this annotation neutralized)${probe.suffix}`;
513
+ }
514
+ lines.push(`${token} annotations (${waiverLines.length}):`);
515
+ lines.push(...waiverLines.length === 0 ? [" (none)"] : waiverLines);
516
+ const exemptLines = [];
517
+ let exemptCount = 0;
518
+ const declared = Object.entries(cfg.exemptRules ?? {});
519
+ if (declared.length > 0) {
520
+ const baselineKeys = /* @__PURE__ */ new Set();
521
+ for (const rel of scanned) {
522
+ for (const f of checkSource(sources.get(rel) ?? "", rel, guardCfg))
523
+ baselineKeys.add(findingKey(f));
524
+ }
525
+ for (const [kind, entry] of declared) {
526
+ const reason = exemptRuleReason(entry);
527
+ for (const pattern of exemptRulePaths(entry)) {
528
+ exemptCount++;
529
+ const probeCfg = {
530
+ ...guardCfg,
531
+ exemptRules: {
532
+ ...guardCfg.exemptRules,
533
+ [kind]: exemptRulePaths(entry).filter((p) => p !== pattern)
534
+ }
535
+ };
536
+ const revealed = /* @__PURE__ */ new Set();
537
+ for (const rel of scanned) {
538
+ for (const f of checkSource(sources.get(rel) ?? "", rel, probeCfg)) {
539
+ if (!baselineKeys.has(findingKey(f))) revealed.add(f.relPath);
540
+ }
541
+ }
542
+ const site = ` ${kind}: "${pattern}"`.padEnd(52);
543
+ const why = reason === null ? " [no reason recorded]" : ` \u2014 ${reason}`;
544
+ if (revealed.size === 0) {
545
+ dead++;
546
+ const unmatched = unmatchedExemptPatterns(guardCfg, scanned).find(
547
+ (u) => u.kind === kind && u.pattern === pattern
548
+ );
549
+ exemptLines.push(
550
+ `${site} SUPPRESSES NOTHING \u2014 ` + (unmatched === void 0 ? "the files it matches have no such finding; delete it" : unmatched.reason === "unknown-kind" ? "it names no guard kind (a typo, or a renamed kind)" : "it matches no scanned file at all") + why
551
+ );
552
+ } else {
553
+ live++;
554
+ exemptLines.push(
555
+ `${site} suppresses findings in ${[...revealed].toSorted().join(", ")}${why}`
556
+ );
557
+ }
558
+ }
559
+ }
560
+ }
561
+ lines.push(`
562
+ basalt.exemptRules entries (${exemptCount}):`);
563
+ lines.push(...exemptLines.length === 0 ? [" (none)"] : exemptLines);
564
+ lines.push(
565
+ `
566
+ ${live} live, ${dead} dead, ${outOfReach} unjudgeable, ${unaccountable} unaccountable (reported as theme-allow-unscoped by a normal run).`
567
+ );
568
+ lines.push(
569
+ `Scope: the ${scanned.length} file(s) check-theme scans under basalt.roots (${resolveRoots(cfg).join(", ")}). A waiver outside them is not audited \u2014 widen roots to cover it, or accept that nothing polices that file.`
570
+ );
571
+ if (dead > 0) {
572
+ lines.push(
573
+ "A dead waiver is not harmless: it is an exception nobody re-checked, and it will silently cover the next real finding on that line."
574
+ );
575
+ }
576
+ console.log(lines.join("\n"));
577
+ return dead > 0 ? 1 : 0;
578
+ }
340
579
  const BLOCK_BEGIN_PREFIX = "<!-- basalt:begin";
341
580
  const BLOCK_END = "<!-- basalt:end -->";
342
581
  const MANIFEST_PATH = ".basalt/manifest.json";
@@ -531,9 +770,24 @@ function managedFiles(peers, placement = ROOT_PLACEMENT) {
531
770
  # The path is RESOLVED from where basalt-ui actually installed, not assumed at the
532
771
  # repo root \u2014 under an isolated linker it lives beside the package that depends on it.
533
772
  # Run \`lefthook install\` once: the file is inert until the git hooks are written.
773
+ #
774
+ # NOTE: an extends target WINS on a colliding key \u2014 a \`run:\`/\`glob:\` you write under one of
775
+ # the preset's command names is discarded silently. \`env:\`, \`exclude:\`, \`skip:\` and your own
776
+ # command names merge. See the preset itself for the sanctioned override seams.
534
777
  extends:
535
778
  - ${ctx.vars.LEFTHOOK_PRESET_PATH.replace(/^\.\//, "")}
536
- `
779
+ ` + // The one seam that has to be RENDERED rather than documented: the preset is read in place
780
+ // out of node_modules, so it cannot template the resolved bin the way check.yml does. Its
781
+ // default (`bunx --no-install basalt-ui`) fails loudly rather than fetching a stranger's
782
+ // copy; this pins it to the local one whenever basalt actually resolved.
783
+ (ctx.vars.BASALT_BIN.startsWith("bunx ") ? "" : `
784
+ # The theme guard runs the LOCAL bin, not a copy \`bunx\` fetches from npm.
785
+ pre-commit:
786
+ commands:
787
+ check-theme:
788
+ env:
789
+ BASALT_BIN: ${ctx.vars.BASALT_BIN}
790
+ `)
537
791
  };
538
792
  const ci = {
539
793
  dest: ".github/workflows/check.yml",
@@ -690,6 +944,60 @@ function detectRoots(cwd) {
690
944
  }
691
945
  return existsSync(resolve(cwd, DEFAULT_ROOT)) ? [DEFAULT_ROOT] : [];
692
946
  }
947
+ function rootCovers(root, detected) {
948
+ const r = root.replace(/\/+$/, "");
949
+ return detected === r || detected.startsWith(`${r}/`);
950
+ }
951
+ function reconcileRoots(cwd, opts) {
952
+ const declared = readBasaltConfig(cwd).roots;
953
+ const detected = detectRoots(cwd);
954
+ if (declared !== void 0) {
955
+ const unscanned = detected.filter((d) => !declared.some((r) => rootCovers(r, d)));
956
+ return { kind: "declared", declared, unscanned };
957
+ }
958
+ if (detected.length === 0) return { kind: "undetectable" };
959
+ if (!opts.write) return { kind: "declared", declared: [], unscanned: detected };
960
+ const wrote = patchPackageJson(cwd, (pkg) => {
961
+ const basalt = pkg["basalt"] ?? {};
962
+ if (basalt["roots"] !== void 0) return false;
963
+ pkg["basalt"] = { ...basalt, roots: detected };
964
+ return true;
965
+ });
966
+ return wrote ? { kind: "wrote", roots: detected } : { kind: "undetectable" };
967
+ }
968
+ function rootsNotices(state) {
969
+ if (state.kind === "wrote") {
970
+ return {
971
+ log: [
972
+ `wrote "basalt": { "roots": ${JSON.stringify(state.roots)} } to package.json \u2014 check-theme, the CI oxfmt globs and the default scan exemption all derive from it. Correct it if your sources live elsewhere.`
973
+ ],
974
+ error: []
975
+ };
976
+ }
977
+ if (state.kind === "undetectable") {
978
+ return {
979
+ log: [],
980
+ error: [
981
+ 'could not infer "basalt.roots" \u2014 no src/ here and no workspace package with one. Set "basalt": { "roots": [...] } in package.json by hand, or check-theme will fail with "0 files scanned".'
982
+ ]
983
+ };
984
+ }
985
+ if (state.unscanned.length === 0) return { log: [], error: [] };
986
+ if (state.declared.length === 0) {
987
+ return {
988
+ log: [],
989
+ error: [
990
+ `"basalt.roots" is not set \u2014 the guard is running on the built-in default ("${DEFAULT_ROOT}"). This layout suggests ${JSON.stringify(state.unscanned)}; declare it in package.json.`
991
+ ]
992
+ };
993
+ }
994
+ return {
995
+ log: [
996
+ `keeping your "basalt.roots" (${JSON.stringify(state.declared)}) \u2014 never overwritten. The layout also has ${JSON.stringify(state.unscanned)}, which nothing scans; add it if it should be guarded, or leave it if the exclusion is deliberate.`
997
+ ],
998
+ error: []
999
+ };
1000
+ }
693
1001
  function stripJsonc(text) {
694
1002
  let out = "";
695
1003
  let inString = false;
@@ -733,10 +1041,79 @@ function parseJsonc(text) {
733
1041
  return null;
734
1042
  }
735
1043
  }
736
- function extendsBasaltPreset(entries) {
737
- return Array.isArray(entries) && entries.some(
1044
+ function basaltPresetEntry(entries) {
1045
+ if (!Array.isArray(entries)) return null;
1046
+ const match = entries.find(
738
1047
  (entry) => typeof entry === "string" && entry.endsWith("basalt-ui/configs/oxlint.json")
739
1048
  );
1049
+ return match ?? null;
1050
+ }
1051
+ const LEFTHOOK_CONFIG_NAMES = ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml"];
1052
+ function findLefthookConfig(dir) {
1053
+ return LEFTHOOK_CONFIG_NAMES.find((name) => existsSync(resolve(dir, name))) ?? null;
1054
+ }
1055
+ function yamlUnquote(raw) {
1056
+ return raw.trim().replace(/^['"]|['"]$/g, "");
1057
+ }
1058
+ function yamlExtendsEntries(text) {
1059
+ const lines = text.split("\n");
1060
+ const out = [];
1061
+ for (const [index, line] of lines.entries()) {
1062
+ const inline = /^extends:\s*\[(.*)\]\s*$/.exec(line ?? "");
1063
+ if (inline !== null) {
1064
+ out.push(...(inline[1] ?? "").split(",").map(yamlUnquote).filter(Boolean));
1065
+ continue;
1066
+ }
1067
+ if (!/^extends:\s*(#.*)?$/.test(line ?? "")) continue;
1068
+ for (const next of lines.slice(index + 1)) {
1069
+ if (/^\s*(#.*)?$/.test(next ?? "")) continue;
1070
+ const item = /^\s*-\s*(.+?)\s*$/.exec(next ?? "");
1071
+ if (item === null) break;
1072
+ out.push(yamlUnquote(item[1] ?? ""));
1073
+ }
1074
+ }
1075
+ return out;
1076
+ }
1077
+ function inspectLefthookSeam(repoRoot) {
1078
+ const file = findLefthookConfig(repoRoot);
1079
+ if (file === null) return { kind: "no-file" };
1080
+ const raw = readIfExists(resolve(repoRoot, file));
1081
+ if (raw === null) return { kind: "no-file" };
1082
+ const entry = yamlExtendsEntries(raw).find((e) => e.endsWith("basalt-ui/configs/lefthook.yml"));
1083
+ if (entry === void 0) return { kind: "unwired", file };
1084
+ return existsSync(resolve(repoRoot, entry)) ? { kind: "ok", file, entry } : { kind: "broken", file, entry };
1085
+ }
1086
+ const LEFTHOOK_GUARD_RUN = /^\s*run:\s*.*\bcheck-theme\b/m;
1087
+ function lefthookDump(repoRoot) {
1088
+ const localBin = resolve(repoRoot, "node_modules/.bin/lefthook");
1089
+ const bin = existsSync(localBin) ? localBin : "lefthook";
1090
+ try {
1091
+ const result = spawnSync(bin, ["dump"], {
1092
+ cwd: repoRoot,
1093
+ encoding: "utf8",
1094
+ timeout: 1e4,
1095
+ stdio: ["ignore", "pipe", "ignore"]
1096
+ });
1097
+ if (result.error !== void 0 || result.status !== 0) return null;
1098
+ return result.stdout;
1099
+ } catch {
1100
+ return null;
1101
+ }
1102
+ }
1103
+ function inspectLefthookGate(repoRoot) {
1104
+ const seam = inspectLefthookSeam(repoRoot);
1105
+ if (seam.kind === "no-file") return { kind: "no-file" };
1106
+ if (seam.kind === "ok") return { kind: "extends-ok", file: seam.file, entry: seam.entry };
1107
+ if (seam.kind === "broken") {
1108
+ return { kind: "extends-broken", file: seam.file, entry: seam.entry };
1109
+ }
1110
+ const dump = lefthookDump(repoRoot);
1111
+ if (dump !== null) {
1112
+ return LEFTHOOK_GUARD_RUN.test(dump) ? { kind: "wired", file: seam.file, proof: "dump" } : { kind: "absent", file: seam.file };
1113
+ }
1114
+ const raw = readIfExists(resolve(repoRoot, seam.file)) ?? "";
1115
+ if (LEFTHOOK_GUARD_RUN.test(raw)) return { kind: "wired", file: seam.file, proof: "config" };
1116
+ return { kind: "advisory", file: seam.file };
740
1117
  }
741
1118
  function mergeOxlintExtends(cwd, presetPath) {
742
1119
  const abs = resolve(cwd, ".oxlintrc.json");
@@ -744,7 +1121,7 @@ function mergeOxlintExtends(cwd, presetPath) {
744
1121
  if (raw === null) return "absent";
745
1122
  const cfg = parseJsonc(raw);
746
1123
  if (cfg === null) return "unreadable";
747
- if (extendsBasaltPreset(cfg["extends"])) return "already";
1124
+ if (basaltPresetEntry(cfg["extends"]) !== null) return "already";
748
1125
  if (stripJsonc(raw) !== raw) return "has-comments";
749
1126
  const existing = Array.isArray(cfg["extends"]) ? cfg["extends"].filter((entry) => typeof entry === "string") : [];
750
1127
  const merged = "extends" in cfg ? {} : { extends: [presetPath] };
@@ -769,21 +1146,7 @@ function init(cwd = process.cwd(), scaffoldFlags = {}) {
769
1146
  `\u26A0 basalt-ui init: basalt-ui does not resolve from ${cwd} or any directory above it \u2014 the seeded \`extends\` paths and CI steps are being written against the repo root by assumption. Install basalt-ui here (or at the repo root) and re-run \`basalt-ui init\` so they resolve.`
770
1147
  );
771
1148
  }
772
- let seededRoots = null;
773
- let rootsUndetectable = false;
774
- if (readBasaltConfig(cwd).roots === void 0) {
775
- const detected = detectRoots(cwd);
776
- if (detected.length === 0) {
777
- rootsUndetectable = true;
778
- } else if (patchPackageJson(cwd, (pkg) => {
779
- const basalt = pkg["basalt"] ?? {};
780
- if (basalt["roots"] !== void 0) return false;
781
- pkg["basalt"] = { ...basalt, roots: detected };
782
- return true;
783
- })) {
784
- seededRoots = detected;
785
- }
786
- }
1149
+ const rootsState = reconcileRoots(cwd, { write: true });
787
1150
  const ctx = renderContext(cwd);
788
1151
  const lintScript = `oxlint . && ${ctx.vars.BASALT_BIN} check-theme`;
789
1152
  const seededLintScript = patchPackageJson(cwd, (pkg) => {
@@ -847,16 +1210,9 @@ Kept (already present \u2014 basalt did not touch them):`);
847
1210
  };
848
1211
  console.log(`basalt-ui init: --merge-lint \u2014 ${mergeMessage[mergeLint]}`);
849
1212
  }
850
- if (seededRoots !== null) {
851
- console.log(
852
- `basalt-ui init: wrote "basalt": { "roots": ${JSON.stringify(seededRoots)} } to package.json \u2014 check-theme, the CI oxfmt globs and the default scan exemption all derive from it. Correct it if your sources live elsewhere.`
853
- );
854
- }
855
- if (rootsUndetectable) {
856
- console.error(
857
- '\u26A0 basalt-ui init: could not infer "basalt.roots" \u2014 no src/ here and no workspace package with one. Set "basalt": { "roots": [...] } in package.json by hand, or check-theme will fail with "0 files scanned".'
858
- );
859
- }
1213
+ const rootsMessages = rootsNotices(rootsState);
1214
+ for (const line of rootsMessages.log) console.log(`basalt-ui init: ${line}`);
1215
+ for (const line of rootsMessages.error) console.error(`\u26A0 basalt-ui init: ${line}`);
860
1216
  if (seededLintScript) {
861
1217
  console.log(`basalt-ui init: added the "lint:basalt" script (${lintScript}) to package.json.`);
862
1218
  }
@@ -921,7 +1277,38 @@ function readOxlintPresetPlugins(pkgRoot) {
921
1277
  return [];
922
1278
  }
923
1279
  }
924
- function sync(opts = {}, cwd = process.cwd()) {
1280
+ function findManifestAbove(dir) {
1281
+ let current = resolve(dir, "..");
1282
+ for (; ; ) {
1283
+ if (existsSync(resolve(current, MANIFEST_PATH))) return current;
1284
+ const parent = resolve(current, "..");
1285
+ if (parent === current) return null;
1286
+ current = parent;
1287
+ }
1288
+ }
1289
+ function sync(opts = {}, invocationCwd = process.cwd()) {
1290
+ const project = resolveProjectDir(invocationCwd);
1291
+ if (project.ambiguous !== null) {
1292
+ console.error(
1293
+ `\u2716 basalt-ui sync: no basalt config at ${invocationCwd}, and ${project.ambiguous.length} workspace packages carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(", ")}) \u2014 run it from one of them, or set BASALT_CWD to pick.`
1294
+ );
1295
+ return 1;
1296
+ }
1297
+ const cwd = project.dir;
1298
+ if (project.relocatedFrom !== null) {
1299
+ console.log(
1300
+ `basalt-ui sync: no basalt config at ${project.relocatedFrom} \u2014 running in ${relativePosix(project.relocatedFrom, cwd)}, where it lives.`
1301
+ );
1302
+ }
1303
+ if (!existsSync(resolve(cwd, MANIFEST_PATH))) {
1304
+ const elsewhere = findManifestAbove(cwd);
1305
+ const wouldWrite = managedFiles(resolvePeerFlags(cwd, {}), resolvePlacement(cwd)).length;
1306
+ console.error(
1307
+ `\u2716 basalt-ui sync: no ${MANIFEST_PATH} at ${cwd} \u2014 refusing to scaffold. \`sync\` refreshes an EXISTING basalt install; creating one is \`basalt-ui init\`'s decision, and continuing here would write ${wouldWrite} managed file(s) into this directory.` + (elsewhere === null ? " No manifest exists at any ancestor either \u2014 run `basalt-ui init` here if this package really is meant to be a consumer." : ` This repo's install is at ${relativePosix(cwd, elsewhere)} (${elsewhere}) \u2014 run sync there, or set BASALT_CWD to it.`)
1308
+ );
1309
+ return 1;
1310
+ }
1311
+ const rootsState = reconcileRoots(cwd, { write: opts.check !== true });
925
1312
  const ctx = renderContext(cwd);
926
1313
  const manifest = readManifest(cwd);
927
1314
  if (!opts.check) migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest);
@@ -929,6 +1316,7 @@ function sync(opts = {}, cwd = process.cwd()) {
929
1316
  const placement = resolvePlacement(cwd);
930
1317
  const files = managedFiles(peers, placement);
931
1318
  let updated = 0;
1319
+ let created = 0;
932
1320
  let recreated = 0;
933
1321
  let skippedDrift = 0;
934
1322
  let staleForCheck = 0;
@@ -940,10 +1328,12 @@ function sync(opts = {}, cwd = process.cwd()) {
940
1328
  missingSources.push(file.source);
941
1329
  continue;
942
1330
  }
1331
+ const tracked = manifest.files[file.dest] !== void 0;
943
1332
  if (file.mode === "seed") {
944
1333
  if (state.current === null && !opts.check) {
945
1334
  manifest.files[file.dest] = writeUnit(file, cwd, state.desired);
946
- recreated++;
1335
+ if (tracked) recreated++;
1336
+ else created++;
947
1337
  }
948
1338
  continue;
949
1339
  }
@@ -961,8 +1351,9 @@ function sync(opts = {}, cwd = process.cwd()) {
961
1351
  staleForCheck++;
962
1352
  if (opts.check) continue;
963
1353
  manifest.files[file.dest] = writeUnit(file, cwd, state.desired);
964
- if (kind === "missing") recreated++;
965
- else updated++;
1354
+ if (kind !== "missing") updated++;
1355
+ else if (tracked) recreated++;
1356
+ else created++;
966
1357
  }
967
1358
  if (!placement.isPackageRepoRoot) {
968
1359
  console.log(
@@ -977,6 +1368,23 @@ function sync(opts = {}, cwd = process.cwd()) {
977
1368
  `basalt-ui sync: skipped src/query-client.ts (found an existing query client at ${relative(cwd, placement.relocatedQueryClient)}) \u2014 import it from there instead of re-seeding at the original path.`
978
1369
  );
979
1370
  }
1371
+ const rootsMessages = rootsNotices(rootsState);
1372
+ for (const line of rootsMessages.log) console.log(`basalt-ui sync: ${line}`);
1373
+ for (const line of rootsMessages.error) console.error(`\u26A0 basalt-ui sync: ${line}`);
1374
+ const install = findBasaltInstall(cwd);
1375
+ const lefthookSeam = inspectLefthookSeam(placement.repoRoot);
1376
+ if (lefthookSeam.kind === "broken") {
1377
+ console.error(
1378
+ `\u26A0 basalt-ui sync: ${lefthookSeam.file} extends "${lefthookSeam.entry}", which does not exist \u2014 lefthook merges a missing extends target into ZERO commands and still exits 0, so there is no pre-commit gate. Repoint it at "${shippedAssetPath(install, placement.repoRoot, "configs/lefthook.yml")}".`
1379
+ );
1380
+ }
1381
+ const oxlintrcRaw = readIfExists(resolve(cwd, ".oxlintrc.json"));
1382
+ const oxlintEntry = oxlintrcRaw === null ? null : basaltPresetEntry(parseJsonc(oxlintrcRaw)?.["extends"]);
1383
+ if (oxlintEntry !== null && !existsSync(resolve(cwd, oxlintEntry))) {
1384
+ console.error(
1385
+ `\u26A0 basalt-ui sync: .oxlintrc.json extends "${oxlintEntry}", which does not exist \u2014 oxlint refuses to start on a missing extends target (\`NotFound\`). Repoint it at "${shippedAssetPath(install, cwd, "configs/oxlint.json")}".`
1386
+ );
1387
+ }
980
1388
  if (opts.check) {
981
1389
  if (driftLines.length > 0) {
982
1390
  console.error("basalt-ui sync --check: locally-drifted managed files:");
@@ -994,7 +1402,7 @@ function sync(opts = {}, cwd = process.cwd()) {
994
1402
  writeFileEnsuringDir(resolve(cwd, MANIFEST_PATH), `${JSON.stringify(manifest, null, 2)}
995
1403
  `);
996
1404
  console.log(
997
- `basalt-ui sync: ${updated} updated, ${recreated} recreated, ${skippedDrift} skipped (drift).`
1405
+ `basalt-ui sync: ${updated} updated, ${created} created, ${recreated} recreated, ${skippedDrift} skipped (drift).`
998
1406
  );
999
1407
  if (driftLines.length > 0) {
1000
1408
  console.log("Locally-edited files were skipped (run with --force to overwrite):");
@@ -1380,15 +1788,53 @@ basalt-ui doctor \u2014 ${cwd}
1380
1788
  skip(
1381
1789
  ".oxlintrc.json does not parse as JSON/JSONC \u2014 cannot tell whether it extends the preset"
1382
1790
  );
1383
- } else if (extendsBasaltPreset(parsed["extends"])) {
1384
- pass(".oxlintrc.json extends the shipped basalt-ui oxlint preset");
1385
1791
  } else {
1386
- fail(
1387
- `.oxlintrc.json does NOT extend the shipped preset \u2014 every basalt/* design rule is off and oxlint reports green without them. Add "extends": ["${shippedAssetPath(install, cwd, "configs/oxlint.json")}"], or re-run \`basalt-ui init --merge-lint\` to splice it in.`
1388
- );
1792
+ const entry = basaltPresetEntry(parsed["extends"]);
1793
+ const correct = shippedAssetPath(install, cwd, "configs/oxlint.json");
1794
+ if (entry === null) {
1795
+ fail(
1796
+ `.oxlintrc.json does NOT extend the shipped preset \u2014 every basalt/* design rule is off and oxlint reports green without them. Add "extends": ["${correct}"], or re-run \`basalt-ui init --merge-lint\` to splice it in.`
1797
+ );
1798
+ } else if (!existsSync(resolve(cwd, entry)) && entry === correct && install.dir !== null) {
1799
+ fail(
1800
+ `.oxlintrc.json extends "${entry}", which does not exist (${resolve(cwd, entry)}) \u2014 basalt-ui resolves there but ships no \`configs/oxlint.json\` at that path, so oxlint refuses to start (\`NotFound\`) and nothing is linted. Reinstall basalt-ui.`
1801
+ );
1802
+ } else if (!existsSync(resolve(cwd, entry))) {
1803
+ fail(
1804
+ `.oxlintrc.json extends "${entry}", which does not exist (${resolve(cwd, entry)}) \u2014 oxlint refuses to start on a missing extends target (\`NotFound\`), so nothing is linted at all. Repoint it at "${correct}", where basalt-ui actually resolves.`
1805
+ );
1806
+ } else {
1807
+ pass(`.oxlintrc.json extends the shipped basalt-ui oxlint preset (${entry})`);
1808
+ }
1389
1809
  }
1390
1810
  }
1391
1811
  }
1812
+ const repoRoot = findRepoRoot(cwd);
1813
+ const lefthookGate = profile === "tokens-only" ? { kind: "n/a" } : inspectLefthookGate(repoRoot);
1814
+ const lefthookCorrect = shippedAssetPath(install, repoRoot, "configs/lefthook.yml");
1815
+ if (lefthookGate.kind === "n/a") {
1816
+ pass("lefthook-preset: n/a \u2014 a tokens-only consumer wires its own hooks");
1817
+ } else if (lefthookGate.kind === "no-file") {
1818
+ pass(`lefthook-preset: n/a \u2014 no lefthook config at ${repoRoot}`);
1819
+ } else if (lefthookGate.kind === "extends-ok") {
1820
+ pass(`${lefthookGate.file} extends the shipped lefthook preset (${lefthookGate.entry})`);
1821
+ } else if (lefthookGate.kind === "wired") {
1822
+ pass(
1823
+ `lefthook-preset: ${lefthookGate.file} does not extend the shipped preset, but a pre-commit command runs check-theme directly \u2014 the gate exists (read from ` + (lefthookGate.proof === "dump" ? "`lefthook dump`, so extends/include/root: are all resolved)." : "the config text; `lefthook dump` was not runnable here, so an `include:` or a remote config was not consulted).")
1824
+ );
1825
+ } else if (lefthookGate.kind === "extends-broken") {
1826
+ fail(
1827
+ `${lefthookGate.file} extends "${lefthookGate.entry}", which does not exist (${resolve(repoRoot, lefthookGate.entry)}) \u2014 lefthook merges a missing extends target into ZERO commands and still exits 0, so this repo has no pre-commit gate and \`lefthook dump\` looks clean. Repoint it at "${lefthookCorrect}".`
1828
+ );
1829
+ } else if (lefthookGate.kind === "absent") {
1830
+ warn(
1831
+ `${lefthookGate.file}: \`lefthook dump\` resolves the merged config and NO pre-commit command runs check-theme \u2014 the theme guard is not gating commits. Either add "extends: [${lefthookCorrect}]", or add your own command running \`bunx basalt-ui check-theme\` (with \`root:\` if it must run in a subdirectory).`
1832
+ );
1833
+ } else {
1834
+ warn(
1835
+ `${lefthookGate.file} names no basalt gate and \`lefthook dump\` could not be run here, so this check cannot tell a missing gate from one wired through an \`include:\` or a remote config it never read \u2014 treat it as advisory. If the guard really is unwired, add "extends: [${lefthookCorrect}]" or your own command running \`bunx basalt-ui check-theme\`.`
1836
+ );
1837
+ }
1392
1838
  const publicDir = resolve(cwd, "public");
1393
1839
  if (profile === "framework" && existsSync(publicDir)) {
1394
1840
  const iconFiles = [
@@ -1541,10 +1987,14 @@ function generatedHeader(version, command, flags) {
1541
1987
  }
1542
1988
  function normalizeColorFunctions(css) {
1543
1989
  return css.replace(/\b(rgba?|hsla?)\(([^()]*)\)/g, (_match, fn, args) => {
1544
- const parts = args.split(",").map((part) => part.trim());
1990
+ const parts = args.split(",").map((part) => normalizeCssNumber(part.trim()));
1545
1991
  return `${fn}(${parts.join(", ")})`;
1546
1992
  });
1547
1993
  }
1994
+ function normalizeCssNumber(arg) {
1995
+ if (!/^\d+\.\d+$/.test(arg)) return arg;
1996
+ return arg.replace(/\.?0+$/, "");
1997
+ }
1548
1998
  const SCHEME_CLASS_SENTINEL = "data-basalt-scheme-class";
1549
1999
  function emitGeneratedCss(content, flags, cwd, command) {
1550
2000
  const out = flagValue(flags, "--out");
@@ -1719,7 +2169,7 @@ async function guardHook(cwd = process.cwd()) {
1719
2169
  rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
1720
2170
  sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
1721
2171
  allowComment: "theme-allow",
1722
- exemptRules: cfg.exemptRules ?? DEFAULT_GUARD_CONFIG.exemptRules,
2172
+ exemptRules: resolveExemptRules(cfg),
1723
2173
  severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
1724
2174
  // Same detection check-theme uses: the hook must never block an edit over advice the app
1725
2175
  // cannot take ("use @mantine/core's Select") in a repo with no Mantine.
@@ -1755,7 +2205,7 @@ async function guardHook(cwd = process.cwd()) {
1755
2205
  }
1756
2206
  return 0;
1757
2207
  }
1758
- const USAGE = "Usage: basalt-ui <init [--with-router] [--with-query] [--merge-lint] | sync [--force] [--check] |\n check-theme | check-coverage | info [--json] | doctor [--tokens-only|--framework] |\n guard-hook | tokens:css | fonts:css | help>\n\ntokens:css [--out <path>] [--check] [--selector-attribute <attr> | --selector-class <class>]\n [--light-class <class>] [--dark-value <v>] [--light-value <v>]\n [--default-scheme <dark|light|none>] [--media-fallback] [--only <core|all>]\n [--no-legacy-aliases]\n Emit the --vx-* stylesheet (stdout unless --out). Defaults reproduce basalt-ui/tokens.css.\n --selector-class emits `:root.dark` instead of an attribute selector (the Tailwind convention).\n --check writes nothing and exits 1 when --out differs from what would be emitted (a CI gate).\n --no-legacy-aliases drops the deprecated camelCase spellings (--vx-accentFill and friends),\n which are emitted by default as aliases of the canonical kebab-case names.\n\nfonts:css [--out <path>] [--check]\n Emit the shipped --basalt-font-* stacks as plain CSS \u2014 the typeface half of the token layer,\n otherwise reachable only by importing styles.css.\n\ndoctor [--tokens-only|--framework]\n Auto-detects a tokens-only consumer (no manifest + no @mantine/core) and checks only what\n applies. A check that cannot RUN is reported as SKIPPED and exits non-zero.\n\ncheck-theme / doctor honour BASALT_CWD, and relocate to the single workspace package carrying a\nbasalt config when invoked from a repo root that has none \u2014 several carrying one is ambiguous\nand exits 1 in BOTH. --tokens-only and --framework are alternatives; passing both is an error.\n\nEvery subcommand accepts --help / -h to print this message and exit without running.";
2208
+ const USAGE = "Usage: basalt-ui <init [--with-router] [--with-query] [--merge-lint] | sync [--force] [--check] |\n check-theme [--audit-allows] | check-coverage | info [--json] |\n doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\ncheck-theme [--tokens-only|--framework] [--audit-allows]\n --audit-allows reports instead of scanning: every `theme-allow` annotation and every\n `basalt.exemptRules` entry, with what each one still suppresses \u2014 proved by re-running the\n guard with that one waiver neutralized, not by reading its text. Exits 1 on a waiver that\n suppresses nothing (dead), which is what makes it usable as a CI gate.\n\ntokens:css [--out <path>] [--check] [--selector-attribute <attr> | --selector-class <class>]\n [--light-class <class>] [--dark-value <v>] [--light-value <v>]\n [--default-scheme <dark|light|none>] [--media-fallback] [--only <core|all>]\n [--no-legacy-aliases]\n Emit the --vx-* stylesheet (stdout unless --out). Defaults reproduce basalt-ui/tokens.css.\n --selector-class emits `:root.dark` instead of an attribute selector (the Tailwind convention).\n --check writes nothing and exits 1 when --out differs from what would be emitted (a CI gate).\n --no-legacy-aliases drops the deprecated camelCase spellings (--vx-accentFill and friends),\n which are emitted by default as aliases of the canonical kebab-case names.\n\nfonts:css [--out <path>] [--check]\n Emit the shipped --basalt-font-* stacks as plain CSS \u2014 the typeface half of the token layer,\n otherwise reachable only by importing styles.css.\n\ndoctor [--tokens-only|--framework]\n Auto-detects a tokens-only consumer (no manifest + no @mantine/core) and checks only what\n applies. A check that cannot RUN is reported as SKIPPED and exits non-zero.\n\ncheck-theme / doctor honour BASALT_CWD, and relocate to the single workspace package carrying a\nbasalt config when invoked from a repo root that has none \u2014 several carrying one is ambiguous\nand exits 1 in BOTH. --tokens-only and --framework are alternatives; passing both is an error.\n\nEvery subcommand accepts --help / -h to print this message and exit without running.";
1759
2209
  function run(argv, cwd = process.cwd()) {
1760
2210
  const [cmd, ...flags] = argv;
1761
2211
  if (cmd === "help" || cmd === "--help" || cmd === "-h" || flags.includes("--help") || flags.includes("-h")) {