basalt-ui 1.19.1 → 1.21.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 (129) hide show
  1. package/AGENTS.md +19 -1
  2. package/CHANGELOG.md +39 -0
  3. package/MIGRATING.md +326 -0
  4. package/README.md +87 -9
  5. package/agent/rules/basalt-app.md +84 -0
  6. package/agent/rules/basalt-charts.md +32 -17
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-mantine.md +25 -10
  9. package/agent/rules/basalt-router.md +79 -15
  10. package/agent/rules/basalt-state.md +21 -3
  11. package/agent/rules/basalt-tokens.md +132 -6
  12. package/agent/skills/basalt-app/SKILL.md +45 -6
  13. package/agent/skills/basalt-charts/SKILL.md +5 -3
  14. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  15. package/configs/check.yml +11 -4
  16. package/configs/lefthook.yml +59 -7
  17. package/configs/oxlint-plugin.js +633 -30
  18. package/configs/oxlint-plugin.test.ts +594 -15
  19. package/configs/oxlint.json +10 -1
  20. package/dist/agent/stick-to-bottom.js +1 -1
  21. package/dist/agent-chat/thread-detail-panel.js +1 -1
  22. package/dist/agent-chat/thread-message.js +2 -2
  23. package/dist/agent-chat/tool-chip.js +1 -1
  24. package/dist/charts/kinds/Bars.d.ts +1 -1
  25. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  26. package/dist/charts/kinds/Donut.d.ts +1 -1
  27. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  28. package/dist/charts/kinds/Donut.js +1 -1
  29. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  30. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  31. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  32. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  33. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  34. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  35. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  36. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  37. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  38. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  39. package/dist/charts/primitives/ChartLegend.js +2 -2
  40. package/dist/charts/primitives/ChartTooltip.js +1 -1
  41. package/dist/cli/index.d.ts +146 -20
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +901 -101
  44. package/dist/cli/toolchain-wiring.test.d.ts +2 -0
  45. package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
  46. package/dist/cli/waiver-audit.test.d.ts +2 -0
  47. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  48. package/dist/dashboard/delta-badge.js +2 -2
  49. package/dist/dashboard/stat-card.js +1 -1
  50. package/dist/data/virtual-list.js +5 -5
  51. package/dist/guard/index.d.ts +101 -2
  52. package/dist/guard/index.d.ts.map +1 -1
  53. package/dist/guard/index.js +474 -62
  54. package/dist/guard/types.d.ts +25 -5
  55. package/dist/guard/types.d.ts.map +1 -1
  56. package/dist/register.d.ts +25 -0
  57. package/dist/register.d.ts.map +1 -1
  58. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  59. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  60. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  61. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  62. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  63. package/dist/router-tanstack/search-param-store.js +31 -3
  64. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  65. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  66. package/dist/shell/app-mobile-nav.module.css +11 -3
  67. package/dist/shell/index.d.ts +12 -5
  68. package/dist/shell/index.d.ts.map +1 -1
  69. package/dist/shell/index.js +24 -6
  70. package/dist/surfaces.d.ts +1 -1
  71. package/dist/surfaces.js +1 -1
  72. package/dist/tokens/index.d.ts +5 -3
  73. package/dist/tokens/index.d.ts.map +1 -1
  74. package/dist/tokens/index.js +7 -1
  75. package/dist/tokens/palette.d.ts +35 -27
  76. package/dist/tokens/palette.d.ts.map +1 -1
  77. package/dist/tokens/palette.js +24 -18
  78. package/dist/tokens/text-scale.test.d.ts +2 -0
  79. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  80. package/dist/tokens.css +21 -17
  81. package/dist/vite-color-mix.js +2 -2
  82. package/dist/vite.d.ts +24 -2
  83. package/dist/vite.d.ts.map +1 -1
  84. package/dist/vite.js +54 -13
  85. package/llms.txt +3 -3
  86. package/package.json +4 -3
  87. package/src/agent/stick-to-bottom.tsx +1 -1
  88. package/src/agent-chat/thread-detail-panel.tsx +1 -1
  89. package/src/agent-chat/thread-message.tsx +3 -3
  90. package/src/agent-chat/tool-chip.tsx +1 -1
  91. package/src/charts/kinds/Bars.tsx +1 -1
  92. package/src/charts/kinds/Donut.tsx +3 -2
  93. package/src/charts/kinds/DualPanel.tsx +2 -2
  94. package/src/charts/kinds/Heatmap.tsx +1 -1
  95. package/src/charts/kinds/MultiLine.tsx +1 -1
  96. package/src/charts/kinds/StackedArea.tsx +1 -1
  97. package/src/charts/kinds/ZonedLine.tsx +1 -1
  98. package/src/charts/primitives/ChartLegend.tsx +2 -2
  99. package/src/charts/primitives/ChartTooltip.tsx +1 -1
  100. package/src/cli/index.ts +1614 -164
  101. package/src/cli/init-sync.test.ts +21 -3
  102. package/src/cli/template-ledger.test.ts +1 -1
  103. package/src/cli/tokens-css.test.ts +27 -14
  104. package/src/cli/toolchain-wiring.test.ts +856 -0
  105. package/src/cli/waiver-audit.test.ts +131 -0
  106. package/src/dashboard/delta-badge.tsx +3 -3
  107. package/src/dashboard/stat-card.tsx +1 -1
  108. package/src/data/virtual-list.tsx +8 -8
  109. package/src/guard/check-source.test.ts +1104 -12
  110. package/src/guard/index.ts +1106 -103
  111. package/src/guard/types.ts +29 -4
  112. package/src/register.ts +32 -0
  113. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  114. package/src/router-tanstack/search-param-store.test.ts +135 -0
  115. package/src/router-tanstack/search-param-store.ts +90 -8
  116. package/src/shell/app-mobile-nav.module.css +11 -3
  117. package/src/shell/app-mobile-nav.tsx +1 -1
  118. package/src/shell/index.test.tsx +52 -0
  119. package/src/shell/index.tsx +64 -13
  120. package/src/surfaces.ts +1 -1
  121. package/src/theme/density-relations.test.ts +8 -5
  122. package/src/theme/spacing.test.ts +2 -0
  123. package/src/theme-toggle/index.tsx +2 -2
  124. package/src/tokens/index.ts +28 -3
  125. package/src/tokens/palette.ts +24 -18
  126. package/src/tokens/text-scale.test.ts +51 -0
  127. package/src/vite-color-mix.ts +2 -2
  128. package/src/vite.test.ts +125 -6
  129. package/src/vite.ts +148 -20
package/dist/cli/index.js CHANGED
@@ -10,10 +10,35 @@ import {
10
10
  } from "node:fs";
11
11
  import { dirname, isAbsolute, relative, resolve } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
- import { checkSource, DEFAULT_GUARD_CONFIG, GUARD_RULES } from "../guard/index.js";
13
+ import {
14
+ checkSource,
15
+ DEFAULT_GUARD_CONFIG,
16
+ GENERATED_HEADER_LINE,
17
+ guardKindRemedy,
18
+ GUARD_RULES,
19
+ guardWaiverHint,
20
+ TOKENS_ONLY_DISABLED_KINDS,
21
+ unmatchedExemptPatterns
22
+ } from "../guard/index.js";
14
23
  import { evaluateGuardHook } from "../guard/guard-hook.js";
15
24
  import { RULE_NAMES, SKILL_NAMES, SURFACES, TOKEN_LAYER_BOUNDARY_SURFACES } from "../surfaces.js";
16
25
  import { buildPaletteCss, deriveSpacing } from "../tokens/index.js";
26
+ function exemptRulePaths(entry) {
27
+ if (entry === void 0) return [];
28
+ return Array.isArray(entry) ? entry : entry.paths;
29
+ }
30
+ function exemptRuleReason(entry) {
31
+ if (entry === void 0 || Array.isArray(entry)) return null;
32
+ return entry.reason.trim().length > 0 ? entry.reason.trim() : null;
33
+ }
34
+ function resolveExemptRules(cfg) {
35
+ if (cfg.exemptRules === void 0) return DEFAULT_GUARD_CONFIG.exemptRules;
36
+ const out = {};
37
+ for (const [kind, entry] of Object.entries(cfg.exemptRules)) {
38
+ out[kind] = exemptRulePaths(entry);
39
+ }
40
+ return out;
41
+ }
17
42
  const DEFAULT_ROOT = "src";
18
43
  const DEFAULT_ROOTS = [DEFAULT_ROOT];
19
44
  function resolveRoots(cfg) {
@@ -61,6 +86,7 @@ function readBasaltConfig(cwd) {
61
86
  return {};
62
87
  }
63
88
  }
89
+ const SCANNABLE_EXT = /\.(?:tsx?|css|html?|webmanifest)$/;
64
90
  function walkSourceFiles(dir, out = []) {
65
91
  let entries;
66
92
  try {
@@ -78,33 +104,149 @@ function walkSourceFiles(dir, out = []) {
78
104
  continue;
79
105
  }
80
106
  if (isDir) walkSourceFiles(abs, out);
81
- else if (/\.(?:tsx?|css)$/.test(name)) out.push(abs);
107
+ else if (SCANNABLE_EXT.test(name)) out.push(abs);
82
108
  }
83
109
  return out;
84
110
  }
85
- const GUARD_KIND_EXPLANATIONS = {
86
- "raw-hex": "raw-hex: route color through VX.* / the Mantine theme instead of a hardcoded hex literal.",
87
- "raw-color-fn": "raw-color-fn: route color through VX.* / the Mantine theme instead of a hardcoded rgb()/hsl() literal.",
88
- "off-identity-accent": "off-identity-accent: use blue/gray or a status hue (red/green/orange/yellow) instead.",
89
- "raw-spacing": "raw-spacing: use the spacing scale token instead of a raw literal.",
90
- "raw-radius": "raw-radius: use the radius scale token instead of a raw literal.",
91
- "raw-surface": "raw-surface: use `VX.surface.*` / a `radius` token / `var(--vx-radius-card)` instead of an inline border/radius/shadow.",
92
- "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.",
93
- "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.",
94
- "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).",
95
- "inline-spacing": "inline-spacing: inline spacing literal \u2014 use the Mantine spacing prop/token (p/m/gap with xs..xl).",
96
- "inline-display": "inline-display: use <Flex>/<Grid>/<Group> instead of an inline display:flex/grid.",
97
- "raw-visx-axis": "raw-visx-axis: raw <AxisLeft>/<AxisBottom>/<AxisRight> in a chart file \u2014 use AxisLeftNumeric / AxisBottomDate / AxisRightNumeric from the charts primitives.",
98
- "raw-motion-value": "raw-motion-value: hardcoded duration/spring/ease in `transition={{...}}` \u2014 use MOTION_DURATION / MOTION_SPRING / MOTION_EASE_STANDARD instead.",
99
- "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.",
100
- "raw-form-control": "raw-form-control: raw <input>/<select>/<textarea> bypasses the entire theme \u2014 use TextInput / NumberInput / Select / Textarea from @mantine/core.",
101
- "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.",
102
- "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."
103
- };
104
- function checkTheme(cwd = process.cwd()) {
105
- const cfg = readBasaltConfig(cwd);
106
- const roots = cfg.roots ?? DEFAULT_ROOTS;
111
+ function relativePosix(fromDir, toPath) {
112
+ const rel = relative(fromDir, toPath).replace(/\\/g, "/");
113
+ if (rel === "") return ".";
114
+ return rel.startsWith(".") ? rel : `./${rel}`;
115
+ }
116
+ const UNRESOLVED_INSTALL = { dir: null, version: null, how: null };
117
+ function basaltInstallAt(dir) {
118
+ const installDir = resolve(dir, "node_modules", "basalt-ui");
119
+ const raw = readIfExists(resolve(installDir, "package.json"));
120
+ if (raw === null) return null;
121
+ try {
122
+ const pkg = JSON.parse(raw);
123
+ return { dir: installDir, version: pkg.version ?? null };
124
+ } catch {
125
+ return { dir: installDir, version: null };
126
+ }
127
+ }
128
+ function findBasaltInstall(cwd) {
129
+ const own = basaltInstallAt(cwd);
130
+ if (own !== null) return { ...own, how: "cwd" };
131
+ let current = dirname(cwd);
132
+ for (; ; ) {
133
+ const found = basaltInstallAt(current);
134
+ if (found !== null) return { ...found, how: "ancestor" };
135
+ const parent = dirname(current);
136
+ if (parent === current) break;
137
+ current = parent;
138
+ }
139
+ for (const pkg of collectWorkspacePackages(cwd).packages) {
140
+ if (pkg.dir === cwd) continue;
141
+ const found = basaltInstallAt(pkg.dir);
142
+ if (found !== null) return { ...found, how: "workspace" };
143
+ }
144
+ return UNRESOLVED_INSTALL;
145
+ }
146
+ function shippedAssetPath(install, fromDir, asset) {
147
+ if (install.dir === null) return `./node_modules/basalt-ui/${asset}`;
148
+ return `${relativePosix(fromDir, install.dir)}/${asset}`;
149
+ }
150
+ function hasBasaltProject(dir) {
151
+ if (existsSync(resolve(dir, MANIFEST_PATH))) return true;
152
+ const raw = readIfExists(resolve(dir, "package.json"));
153
+ if (raw === null) return false;
154
+ try {
155
+ return JSON.parse(raw).basalt !== void 0;
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+ function resolveProjectDir(cwd) {
161
+ const override = process.env["BASALT_CWD"];
162
+ if (override !== void 0 && override.length > 0) {
163
+ const dir = isAbsolute(override) ? override : resolve(cwd, override);
164
+ return { dir, relocatedFrom: dir === cwd ? null : cwd, ambiguous: null };
165
+ }
166
+ if (hasBasaltProject(cwd)) return { dir: cwd, relocatedFrom: null, ambiguous: null };
167
+ const candidates = collectWorkspacePackages(cwd).packages.filter((pkg) => pkg.dir !== cwd && hasBasaltProject(pkg.dir)).map((pkg) => pkg.dir);
168
+ if (candidates.length === 1) {
169
+ return { dir: candidates[0], relocatedFrom: cwd, ambiguous: null };
170
+ }
171
+ if (candidates.length > 1) return { dir: cwd, relocatedFrom: null, ambiguous: candidates };
172
+ return { dir: cwd, relocatedFrom: null, ambiguous: null };
173
+ }
174
+ function declaredProfile(cfg, flags) {
175
+ if (flags.includes("--tokens-only")) return "tokens-only";
176
+ if (flags.includes("--framework")) return "framework";
177
+ return cfg.profile === "tokens-only" ? "tokens-only" : "framework";
178
+ }
179
+ function conflictingProfileFlags(flags) {
180
+ return flags.includes("--tokens-only") && flags.includes("--framework");
181
+ }
182
+ function workspaceHasMantine(cwd) {
183
+ if (hasDependency(cwd, "@mantine/core")) return true;
184
+ return collectWorkspacePackages(cwd).packages.some(
185
+ (pkg) => hasDependency(pkg.dir, "@mantine/core")
186
+ );
187
+ }
188
+ function inferredProfile(cwd, cfg, flags) {
189
+ if (flags.includes("--tokens-only") || flags.includes("--framework") || cfg.profile !== void 0) {
190
+ return declaredProfile(cfg, flags);
191
+ }
192
+ return !existsSync(resolve(cwd, MANIFEST_PATH)) && !workspaceHasMantine(cwd) ? "tokens-only" : "framework";
193
+ }
194
+ function scannableFiles(cwd, cfg) {
107
195
  const exempt = new Set(cfg.exempt ?? defaultExempt(cfg));
196
+ const seen = /* @__PURE__ */ new Set();
197
+ const out = [];
198
+ const add = (abs) => {
199
+ const rel = relative(cwd, abs).replace(/\\/g, "/");
200
+ if (rel.startsWith("..") || SKIP.test(rel) || exempt.has(rel) || seen.has(rel)) return;
201
+ seen.add(rel);
202
+ out.push(rel);
203
+ };
204
+ for (const root of resolveRoots(cfg)) {
205
+ const rootAbs = resolve(cwd, root);
206
+ for (const f of walkSourceFiles(rootAbs)) add(f);
207
+ for (const f of appShellFiles(rootAbs)) add(f);
208
+ }
209
+ for (const rel of cfg.include ?? []) add(resolve(cwd, rel));
210
+ return out;
211
+ }
212
+ function appShellFiles(rootAbs) {
213
+ const appDir = dirname(rootAbs);
214
+ const out = [];
215
+ for (const name of ["index.html", "index.htm"]) {
216
+ const abs = resolve(appDir, name);
217
+ if (existsSync(abs)) out.push(abs);
218
+ }
219
+ out.push(...walkSourceFiles(resolve(appDir, "public")));
220
+ return out;
221
+ }
222
+ function checkTheme(invocationCwd = process.cwd(), flags = []) {
223
+ if (conflictingProfileFlags(flags)) {
224
+ console.error(
225
+ "basalt-ui check-theme: --tokens-only and --framework are alternatives \u2014 pass one."
226
+ );
227
+ return 1;
228
+ }
229
+ const project = resolveProjectDir(invocationCwd);
230
+ if (project.ambiguous !== null) {
231
+ console.error(
232
+ `\u2716 basalt-ui check-theme: 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.`
233
+ );
234
+ return 1;
235
+ }
236
+ const cwd = project.dir;
237
+ if (project.relocatedFrom !== null) {
238
+ console.log(
239
+ `basalt-ui check-theme: no basalt config at ${project.relocatedFrom} \u2014 running in ${relativePosix(project.relocatedFrom, cwd)}, where it lives.`
240
+ );
241
+ }
242
+ const cfg = readBasaltConfig(cwd);
243
+ const roots = resolveRoots(cfg);
244
+ const profile = declaredProfile(cfg, flags);
245
+ if (profile === "tokens-only") {
246
+ console.log(
247
+ `basalt-ui check-theme: tokens-only profile \u2014 ${TOKENS_ONLY_DISABLED_KINDS.size} Mantine-coupled kinds are off; the color and typography kinds still apply. Pass --framework to force the full set.`
248
+ );
249
+ }
108
250
  const guardCfg = {
109
251
  spacingSteps: cfg.spacingSteps ?? DEFAULT_GUARD_CONFIG.spacingSteps,
110
252
  rawRadius: cfg.rawRadius ?? DEFAULT_GUARD_CONFIG.rawRadius,
@@ -123,21 +265,16 @@ function checkTheme(cwd = process.cwd()) {
123
265
  rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
124
266
  sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
125
267
  allowComment: "theme-allow",
126
- exemptRules: cfg.exemptRules ?? DEFAULT_GUARD_CONFIG.exemptRules,
127
- severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity
268
+ exemptRules: resolveExemptRules(cfg),
269
+ severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
270
+ ...profile === "tokens-only" ? { profile: "tokens-only" } : {}
128
271
  };
129
272
  const findings = [];
130
- let scannedCount = 0;
131
- for (const root of roots) {
132
- for (const f of walkSourceFiles(resolve(cwd, root))) {
133
- const rel = relative(cwd, f).replace(/\\/g, "/");
134
- if (SKIP.test(rel) || exempt.has(rel)) continue;
135
- scannedCount += 1;
136
- const text = readFileSync(f, "utf8");
137
- findings.push(...checkSource(text, rel, guardCfg));
138
- }
273
+ const scanned = scannableFiles(cwd, cfg);
274
+ for (const rel of scanned) {
275
+ findings.push(...checkSource(readFileSync(resolve(cwd, rel), "utf8"), rel, guardCfg));
139
276
  }
140
- if (scannedCount === 0) {
277
+ if (scanned.length === 0) {
141
278
  if (cfg.roots === void 0) {
142
279
  console.error(
143
280
  `\u2716 basalt-ui check-theme: 0 files scanned \u2014 no "basalt.roots" configured in package.json, and the built-in default roots (${DEFAULT_ROOTS.join(", ")}) matched zero files. Set "basalt": { "roots": [...] } in package.json to point at your source directories.`
@@ -149,6 +286,12 @@ function checkTheme(cwd = process.cwd()) {
149
286
  }
150
287
  return 1;
151
288
  }
289
+ if (flags.includes("--audit-allows")) return auditAllows(cwd, cfg, guardCfg, scanned);
290
+ for (const unmatched of unmatchedExemptPatterns(guardCfg, scanned)) {
291
+ console.error(
292
+ `\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.")
293
+ );
294
+ }
152
295
  if (findings.length === 0) {
153
296
  console.log("\u2713 Theme guard: no off-palette colors.");
154
297
  return 0;
@@ -185,14 +328,9 @@ function checkTheme(cwd = process.cwd()) {
185
328
  report(errors, `\u2716 Theme guard: ${errors.length} off-palette / off-identity violation(s)
186
329
  `);
187
330
  }
188
- const presentKinds = new Set(findings.map((f) => f.kind));
189
- const explanations = Object.keys(GUARD_KIND_EXPLANATIONS).filter((kind) => presentKinds.has(kind)).map((kind) => GUARD_KIND_EXPLANATIONS[kind]);
190
- const fixLine = [
191
- "Fix:",
192
- ...explanations,
193
- "Add a `theme-allow` comment for a deliberate exception."
194
- ];
195
- console.error(fixLine.join(" "));
331
+ const presentKinds = [...new Set(findings.map((f) => f.kind))].toSorted();
332
+ const waiverHints = [...new Set(findings.map((f) => guardWaiverHint(f.relPath)))].toSorted();
333
+ console.error(["Fix:", ...presentKinds.map(guardKindRemedy), ...waiverHints].join(" "));
196
334
  if (findings.some((f) => SERIES_MODULE_HINT_RE.test(f.relPath))) {
197
335
  console.error(
198
336
  'Hint: if a flagged file IS your palette/series source, exempt it via "basalt": { "exempt": ["<path>"] } in package.json.'
@@ -200,6 +338,130 @@ function checkTheme(cwd = process.cwd()) {
200
338
  }
201
339
  return errors.length > 0 ? 1 : 0;
202
340
  }
341
+ const AUDIT_NEUTRALIZED_TOKEN = "basalt-audit-neutralized";
342
+ function findingKey(f) {
343
+ return `${f.relPath}:${f.line}:${f.kind}`;
344
+ }
345
+ const AUDIT_ANNOTATION_PREFIX = /(?:^|\/\/|\/\*|<!--|^\s*\*)\s*$/;
346
+ function annotationRuleIds(rest) {
347
+ const head = (rest.replace(/^-file\b/, "").split(/—|–|:|\s-\s/)[0] ?? "").trim();
348
+ return head.split(",").map((word) => word.trim().replace(/^basalt\//, "")).filter((word) => /^[a-z][a-z0-9-]*$/.test(word));
349
+ }
350
+ function isAllowAnnotationLine(line, token) {
351
+ const at = line.indexOf(token);
352
+ if (at === -1) return false;
353
+ if (line.includes(`"basalt:${token}`)) return true;
354
+ return AUDIT_ANNOTATION_PREFIX.test(line.slice(0, at));
355
+ }
356
+ function auditAllows(cwd, cfg, guardCfg, scanned) {
357
+ const token = guardCfg.allowComment;
358
+ const lines = [`
359
+ basalt-ui check-theme --audit-allows \u2014 ${cwd}
360
+ `];
361
+ let dead = 0;
362
+ let live = 0;
363
+ let outOfReach = 0;
364
+ let unaccountable = 0;
365
+ const waiverLines = [];
366
+ const sources = /* @__PURE__ */ new Map();
367
+ for (const rel of scanned) sources.set(rel, readFileSync(resolve(cwd, rel), "utf8"));
368
+ for (const rel of scanned) {
369
+ const text = sources.get(rel) ?? "";
370
+ if (!text.includes(token)) continue;
371
+ const baseline = checkSource(text, rel, guardCfg);
372
+ const baselineKeys = new Set(
373
+ baseline.filter((f) => f.kind !== "theme-allow-unscoped").map(findingKey)
374
+ );
375
+ const unscopedAt = new Map(
376
+ baseline.filter((f) => f.kind === "theme-allow-unscoped").map((f) => [f.line, f.token])
377
+ );
378
+ const fileLines = text.split("\n");
379
+ for (const [index, line] of fileLines.entries()) {
380
+ if (!isAllowAnnotationLine(line ?? "", token)) continue;
381
+ const probe = [...fileLines];
382
+ probe[index] = (line ?? "").replaceAll(token, AUDIT_NEUTRALIZED_TOKEN);
383
+ const revealed = checkSource(probe.join("\n"), rel, guardCfg).filter((f) => f.kind !== "theme-allow-unscoped" && !baselineKeys.has(findingKey(f))).map((f) => `${f.kind}@${f.line}`);
384
+ const note = unscopedAt.get(index + 1);
385
+ if (note !== void 0) unaccountable++;
386
+ const site = ` ${rel}:${index + 1}`.padEnd(52);
387
+ const suffix = note === void 0 ? "" : ` [${note}]`;
388
+ const ids = annotationRuleIds((line ?? "").slice((line ?? "").indexOf(token) + token.length));
389
+ if (revealed.length > 0) {
390
+ live++;
391
+ waiverLines.push(`${site} suppresses ${[...new Set(revealed)].join(", ")}${suffix}`);
392
+ } else if (ids.length > 0 && !ids.some((id) => Object.hasOwn(GUARD_RULES, id))) {
393
+ outOfReach++;
394
+ waiverLines.push(
395
+ `${site} scoped to ${ids.join(", ")} \u2014 not a check-theme kind, so this audit cannot judge it (an oxlint plugin rule, or a typo theme-allow-unscoped would report)${suffix}`
396
+ );
397
+ } else {
398
+ dead++;
399
+ waiverLines.push(`${site} SUPPRESSES NOTHING \u2014 dead, delete it${suffix}`);
400
+ }
401
+ }
402
+ }
403
+ lines.push(`${token} annotations (${waiverLines.length}):`);
404
+ lines.push(...waiverLines.length === 0 ? [" (none)"] : waiverLines);
405
+ const exemptLines = [];
406
+ let exemptCount = 0;
407
+ const declared = Object.entries(cfg.exemptRules ?? {});
408
+ if (declared.length > 0) {
409
+ const baselineKeys = /* @__PURE__ */ new Set();
410
+ for (const rel of scanned) {
411
+ for (const f of checkSource(sources.get(rel) ?? "", rel, guardCfg))
412
+ baselineKeys.add(findingKey(f));
413
+ }
414
+ for (const [kind, entry] of declared) {
415
+ const reason = exemptRuleReason(entry);
416
+ for (const pattern of exemptRulePaths(entry)) {
417
+ exemptCount++;
418
+ const probeCfg = {
419
+ ...guardCfg,
420
+ exemptRules: {
421
+ ...guardCfg.exemptRules,
422
+ [kind]: exemptRulePaths(entry).filter((p) => p !== pattern)
423
+ }
424
+ };
425
+ const revealed = /* @__PURE__ */ new Set();
426
+ for (const rel of scanned) {
427
+ for (const f of checkSource(sources.get(rel) ?? "", rel, probeCfg)) {
428
+ if (!baselineKeys.has(findingKey(f))) revealed.add(f.relPath);
429
+ }
430
+ }
431
+ const site = ` ${kind}: "${pattern}"`.padEnd(52);
432
+ const why = reason === null ? " [no reason recorded]" : ` \u2014 ${reason}`;
433
+ if (revealed.size === 0) {
434
+ dead++;
435
+ const unmatched = unmatchedExemptPatterns(guardCfg, scanned).find(
436
+ (u) => u.kind === kind && u.pattern === pattern
437
+ );
438
+ exemptLines.push(
439
+ `${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
440
+ );
441
+ } else {
442
+ live++;
443
+ exemptLines.push(
444
+ `${site} suppresses findings in ${[...revealed].toSorted().join(", ")}${why}`
445
+ );
446
+ }
447
+ }
448
+ }
449
+ }
450
+ lines.push(`
451
+ basalt.exemptRules entries (${exemptCount}):`);
452
+ lines.push(...exemptLines.length === 0 ? [" (none)"] : exemptLines);
453
+ lines.push(
454
+ `
455
+ ${live} live, ${dead} dead, ${outOfReach} outside check-theme's reach, ${unaccountable} unaccountable (reported as theme-allow-unscoped by a normal run).`
456
+ );
457
+ if (dead > 0) {
458
+ lines.push(
459
+ "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."
460
+ );
461
+ }
462
+ console.log(lines.join("\n"));
463
+ return dead > 0 ? 1 : 0;
464
+ }
203
465
  const BLOCK_BEGIN_PREFIX = "<!-- basalt:begin";
204
466
  const BLOCK_END = "<!-- basalt:end -->";
205
467
  const MANIFEST_PATH = ".basalt/manifest.json";
@@ -230,12 +492,17 @@ function readFrameworkVersion(pkgRoot) {
230
492
  }
231
493
  }
232
494
  function buildTemplateVars(pkgRoot, cwd, cfg) {
495
+ const install = findBasaltInstall(cwd);
496
+ const binDir = install.dir === null ? null : resolve(install.dir, "..", ".bin");
233
497
  return {
234
498
  APP_NAME: readPackageName(cwd),
235
499
  BASALT_VERSION: readFrameworkVersion(pkgRoot),
236
500
  ACCENT_HUE: cfg.accentHue ?? "blue",
237
501
  SERIES_MODULE_PATH: resolveSeriesModulePath(cfg),
238
- ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(" ")
502
+ ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(" "),
503
+ OXLINT_PRESET_PATH: shippedAssetPath(install, cwd, "configs/oxlint.json"),
504
+ LEFTHOOK_PRESET_PATH: shippedAssetPath(install, cwd, "configs/lefthook.yml"),
505
+ BASALT_BIN: binDir === null ? "bunx basalt-ui" : `${relativePosix(cwd, binDir).replace(/^\.\//, "")}/basalt-ui`
239
506
  };
240
507
  }
241
508
  function fillTemplate(tpl, vars) {
@@ -383,7 +650,30 @@ function managedFiles(peers, placement = ROOT_PLACEMENT) {
383
650
  dest: "lefthook.yml",
384
651
  mode: "seed",
385
652
  source: "configs/lefthook.yml",
386
- render: () => "# Seeded by basalt-ui init \u2014 you own this file; sync never touches it again.\n# The extends target supplies oxlint + oxfmt + check-theme pre-commit jobs and\n# auto-updates with the basalt-ui package. Add your own commands alongside.\nextends:\n - node_modules/basalt-ui/configs/lefthook.yml\n"
653
+ render: (ctx) => `# Seeded by basalt-ui init \u2014 you own this file; sync never touches it again.
654
+ # The extends target supplies oxlint + oxfmt + check-theme pre-commit jobs and
655
+ # auto-updates with the basalt-ui package. Add your own commands alongside.
656
+ # The path is RESOLVED from where basalt-ui actually installed, not assumed at the
657
+ # repo root \u2014 under an isolated linker it lives beside the package that depends on it.
658
+ # Run \`lefthook install\` once: the file is inert until the git hooks are written.
659
+ #
660
+ # NOTE: an extends target WINS on a colliding key \u2014 a \`run:\`/\`glob:\` you write under one of
661
+ # the preset's command names is discarded silently. \`env:\`, \`exclude:\`, \`skip:\` and your own
662
+ # command names merge. See the preset itself for the sanctioned override seams.
663
+ extends:
664
+ - ${ctx.vars.LEFTHOOK_PRESET_PATH.replace(/^\.\//, "")}
665
+ ` + // The one seam that has to be RENDERED rather than documented: the preset is read in place
666
+ // out of node_modules, so it cannot template the resolved bin the way check.yml does. Its
667
+ // default (`bunx --no-install basalt-ui`) fails loudly rather than fetching a stranger's
668
+ // copy; this pins it to the local one whenever basalt actually resolved.
669
+ (ctx.vars.BASALT_BIN.startsWith("bunx ") ? "" : `
670
+ # The theme guard runs the LOCAL bin, not a copy \`bunx\` fetches from npm.
671
+ pre-commit:
672
+ commands:
673
+ check-theme:
674
+ env:
675
+ BASALT_BIN: ${ctx.vars.BASALT_BIN}
676
+ `)
387
677
  };
388
678
  const ci = {
389
679
  dest: ".github/workflows/check.yml",
@@ -399,7 +689,10 @@ function managedFiles(peers, placement = ROOT_PLACEMENT) {
399
689
  dest: ".oxlintrc.json",
400
690
  mode: "seed",
401
691
  source: "configs/oxlint.json",
402
- render: () => '{\n "extends": ["./node_modules/basalt-ui/configs/oxlint.json"]\n}\n'
692
+ render: (ctx) => `{
693
+ "extends": ["${ctx.vars.OXLINT_PRESET_PATH}"]
694
+ }
695
+ `
403
696
  };
404
697
  const queryClient = {
405
698
  dest: "src/query-client.ts",
@@ -510,15 +803,219 @@ function renderContext(cwd) {
510
803
  const pkgRoot = packageRoot();
511
804
  return { pkgRoot, vars: buildTemplateVars(pkgRoot, cwd, readBasaltConfig(cwd)) };
512
805
  }
806
+ function patchPackageJson(cwd, mutate) {
807
+ const abs = resolve(cwd, "package.json");
808
+ const raw = readIfExists(abs);
809
+ if (raw === null) return false;
810
+ let pkg;
811
+ try {
812
+ pkg = JSON.parse(raw);
813
+ } catch {
814
+ return false;
815
+ }
816
+ if (!mutate(pkg)) return false;
817
+ const indent = /\n([ \t]+)"/.exec(raw)?.[1] ?? " ";
818
+ writeFileSync(abs, JSON.stringify(pkg, null, indent) + (raw.endsWith("\n") ? "\n" : ""));
819
+ return true;
820
+ }
821
+ function detectRoots(cwd) {
822
+ const workspacePackages = collectWorkspacePackages(cwd).packages.filter((pkg) => pkg.dir !== cwd);
823
+ const withSrc = workspacePackages.filter((pkg) => existsSync(resolve(pkg.dir, "src")));
824
+ const uiPackages = withSrc.filter(
825
+ (pkg) => hasDependency(pkg.dir, "basalt-ui") || hasDependency(pkg.dir, "@mantine/core")
826
+ );
827
+ const chosen = uiPackages.length > 0 ? uiPackages : withSrc;
828
+ if (chosen.length > 0) {
829
+ return chosen.map((pkg) => relativePosix(cwd, resolve(pkg.dir, "src")).replace(/^\.\//, ""));
830
+ }
831
+ return existsSync(resolve(cwd, DEFAULT_ROOT)) ? [DEFAULT_ROOT] : [];
832
+ }
833
+ function rootCovers(root, detected) {
834
+ const r = root.replace(/\/+$/, "");
835
+ return detected === r || detected.startsWith(`${r}/`);
836
+ }
837
+ function reconcileRoots(cwd, opts) {
838
+ const declared = readBasaltConfig(cwd).roots;
839
+ const detected = detectRoots(cwd);
840
+ if (declared !== void 0) {
841
+ const unscanned = detected.filter((d) => !declared.some((r) => rootCovers(r, d)));
842
+ return { kind: "declared", declared, unscanned };
843
+ }
844
+ if (detected.length === 0) return { kind: "undetectable" };
845
+ if (!opts.write) return { kind: "declared", declared: [], unscanned: detected };
846
+ const wrote = patchPackageJson(cwd, (pkg) => {
847
+ const basalt = pkg["basalt"] ?? {};
848
+ if (basalt["roots"] !== void 0) return false;
849
+ pkg["basalt"] = { ...basalt, roots: detected };
850
+ return true;
851
+ });
852
+ return wrote ? { kind: "wrote", roots: detected } : { kind: "undetectable" };
853
+ }
854
+ function rootsNotices(state) {
855
+ if (state.kind === "wrote") {
856
+ return {
857
+ log: [
858
+ `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.`
859
+ ],
860
+ error: []
861
+ };
862
+ }
863
+ if (state.kind === "undetectable") {
864
+ return {
865
+ log: [],
866
+ error: [
867
+ '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".'
868
+ ]
869
+ };
870
+ }
871
+ if (state.unscanned.length === 0) return { log: [], error: [] };
872
+ if (state.declared.length === 0) {
873
+ return {
874
+ log: [],
875
+ error: [
876
+ `"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.`
877
+ ]
878
+ };
879
+ }
880
+ return {
881
+ log: [
882
+ `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.`
883
+ ],
884
+ error: []
885
+ };
886
+ }
887
+ function stripJsonc(text) {
888
+ let out = "";
889
+ let inString = false;
890
+ let escaped = false;
891
+ let i = 0;
892
+ while (i < text.length) {
893
+ const ch = text[i];
894
+ if (inString) {
895
+ out += ch;
896
+ if (escaped) escaped = false;
897
+ else if (ch === "\\") escaped = true;
898
+ else if (ch === '"') inString = false;
899
+ i += 1;
900
+ continue;
901
+ }
902
+ if (ch === '"') {
903
+ inString = true;
904
+ out += ch;
905
+ i += 1;
906
+ continue;
907
+ }
908
+ if (ch === "/" && text[i + 1] === "/") {
909
+ while (i < text.length && text[i] !== "\n") i += 1;
910
+ continue;
911
+ }
912
+ if (ch === "/" && text[i + 1] === "*") {
913
+ i += 2;
914
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i += 1;
915
+ i += 2;
916
+ continue;
917
+ }
918
+ out += ch;
919
+ i += 1;
920
+ }
921
+ return out.replace(/,(\s*[}\]])/g, "$1");
922
+ }
923
+ function parseJsonc(text) {
924
+ try {
925
+ return JSON.parse(stripJsonc(text));
926
+ } catch {
927
+ return null;
928
+ }
929
+ }
930
+ function basaltPresetEntry(entries) {
931
+ if (!Array.isArray(entries)) return null;
932
+ const match = entries.find(
933
+ (entry) => typeof entry === "string" && entry.endsWith("basalt-ui/configs/oxlint.json")
934
+ );
935
+ return match ?? null;
936
+ }
937
+ const LEFTHOOK_CONFIG_NAMES = ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml"];
938
+ function findLefthookConfig(dir) {
939
+ return LEFTHOOK_CONFIG_NAMES.find((name) => existsSync(resolve(dir, name))) ?? null;
940
+ }
941
+ function yamlUnquote(raw) {
942
+ return raw.trim().replace(/^['"]|['"]$/g, "");
943
+ }
944
+ function yamlExtendsEntries(text) {
945
+ const lines = text.split("\n");
946
+ const out = [];
947
+ for (const [index, line] of lines.entries()) {
948
+ const inline = /^extends:\s*\[(.*)\]\s*$/.exec(line ?? "");
949
+ if (inline !== null) {
950
+ out.push(...(inline[1] ?? "").split(",").map(yamlUnquote).filter(Boolean));
951
+ continue;
952
+ }
953
+ if (!/^extends:\s*(#.*)?$/.test(line ?? "")) continue;
954
+ for (const next of lines.slice(index + 1)) {
955
+ if (/^\s*(#.*)?$/.test(next ?? "")) continue;
956
+ const item = /^\s*-\s*(.+?)\s*$/.exec(next ?? "");
957
+ if (item === null) break;
958
+ out.push(yamlUnquote(item[1] ?? ""));
959
+ }
960
+ }
961
+ return out;
962
+ }
963
+ function inspectLefthookSeam(repoRoot) {
964
+ const file = findLefthookConfig(repoRoot);
965
+ if (file === null) return { kind: "no-file" };
966
+ const raw = readIfExists(resolve(repoRoot, file));
967
+ if (raw === null) return { kind: "no-file" };
968
+ const entry = yamlExtendsEntries(raw).find((e) => e.endsWith("basalt-ui/configs/lefthook.yml"));
969
+ if (entry === void 0) return { kind: "unwired", file };
970
+ return existsSync(resolve(repoRoot, entry)) ? { kind: "ok", file, entry } : { kind: "broken", file, entry };
971
+ }
972
+ function mergeOxlintExtends(cwd, presetPath) {
973
+ const abs = resolve(cwd, ".oxlintrc.json");
974
+ const raw = readIfExists(abs);
975
+ if (raw === null) return "absent";
976
+ const cfg = parseJsonc(raw);
977
+ if (cfg === null) return "unreadable";
978
+ if (basaltPresetEntry(cfg["extends"]) !== null) return "already";
979
+ if (stripJsonc(raw) !== raw) return "has-comments";
980
+ const existing = Array.isArray(cfg["extends"]) ? cfg["extends"].filter((entry) => typeof entry === "string") : [];
981
+ const merged = "extends" in cfg ? {} : { extends: [presetPath] };
982
+ for (const [key, value] of Object.entries(cfg)) {
983
+ merged[key] = key === "extends" ? [presetPath, ...existing] : value;
984
+ }
985
+ const indent = /\n([ \t]+)"/.exec(raw)?.[1] ?? " ";
986
+ writeFileSync(abs, JSON.stringify(merged, null, indent) + (raw.endsWith("\n") ? "\n" : ""));
987
+ return "added";
988
+ }
989
+ const KEPT_FILE_COST = {
990
+ ".oxlintrc.json": "the shipped oxlint preset (the basalt/* design rules AND the jsx-a11y / import / promise / unicorn plugins) is NOT active \u2014 nothing else turns it on, and every gate stays green without it. Re-run with `--merge-lint` to splice the extends in, or add it by hand.",
991
+ "lefthook.yml": "the pre-commit oxlint / oxfmt / check-theme jobs are not wired \u2014 add `extends: [<preset>]` to your own file.",
992
+ ".github/workflows/check.yml": "CI does not run check-theme or the sync drift gate \u2014 copy those steps into your own workflow.",
993
+ ".oxfmtrc.json": "your existing formatter config stands; basalt formats nothing on its own.",
994
+ "DESIGN.md": "the design brief Claude reads is yours, not the seeded one."
995
+ };
513
996
  function init(cwd = process.cwd(), scaffoldFlags = {}) {
997
+ const install = findBasaltInstall(cwd);
998
+ if (install.dir === null) {
999
+ console.error(
1000
+ `\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.`
1001
+ );
1002
+ }
1003
+ const rootsState = reconcileRoots(cwd, { write: true });
514
1004
  const ctx = renderContext(cwd);
1005
+ const lintScript = `oxlint . && ${ctx.vars.BASALT_BIN} check-theme`;
1006
+ const seededLintScript = patchPackageJson(cwd, (pkg) => {
1007
+ const scripts = pkg["scripts"] ?? {};
1008
+ if (scripts["lint:basalt"] !== void 0) return false;
1009
+ pkg["scripts"] = { ...scripts, "lint:basalt": lintScript };
1010
+ return true;
1011
+ });
515
1012
  const manifest = readManifest(cwd);
516
1013
  migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest);
517
1014
  const peers = resolvePeerFlags(cwd, scaffoldFlags);
518
1015
  const placement = resolvePlacement(cwd);
519
1016
  const files = managedFiles(peers, placement);
520
- let written = 0;
521
- let skipped = 0;
1017
+ const writtenFiles = [];
1018
+ const keptFiles = [];
522
1019
  const missingSources = [];
523
1020
  for (const file of files) {
524
1021
  const state = unitState(file, cwd, ctx);
@@ -529,29 +1026,56 @@ function init(cwd = process.cwd(), scaffoldFlags = {}) {
529
1026
  const destExists = existsSync(resolve(cwd, file.dest));
530
1027
  if (!file.markers && destExists) {
531
1028
  manifest.files[file.dest] = sha256(normalizeForLedger(state.desired));
532
- skipped++;
1029
+ keptFiles.push(file.dest);
533
1030
  continue;
534
1031
  }
535
1032
  const hash = writeUnit(file, cwd, state.desired);
536
1033
  manifest.files[file.dest] = hash;
537
- written++;
1034
+ writtenFiles.push(file.dest);
538
1035
  }
1036
+ const mergeLint = scaffoldFlags.mergeLint === true ? mergeOxlintExtends(cwd, ctx.vars.OXLINT_PRESET_PATH) : null;
539
1037
  manifest.basaltVersion = ctx.vars.BASALT_VERSION;
540
1038
  manifest.spacingScale = { ...deriveSpacing(0).scale };
541
1039
  writeFileEnsuringDir(resolve(cwd, MANIFEST_PATH), `${JSON.stringify(manifest, null, 2)}
542
1040
  `);
543
- console.log(`basalt-ui init: ${written} written, ${skipped} kept, manifest at ${MANIFEST_PATH}`);
1041
+ console.log(
1042
+ `basalt-ui init: ${writtenFiles.length} written, ${keptFiles.length} kept, manifest at ${MANIFEST_PATH}`
1043
+ );
544
1044
  if (missingSources.length > 0) {
545
1045
  console.log(
546
1046
  `basalt-ui init: ${missingSources.length} shipped asset(s) not present, skipped: ${missingSources.join(", ")}`
547
1047
  );
548
1048
  }
1049
+ if (keptFiles.length > 0) {
1050
+ console.log(`
1051
+ Kept (already present \u2014 basalt did not touch them):`);
1052
+ for (const dest of keptFiles) {
1053
+ const cost = KEPT_FILE_COST[dest];
1054
+ console.log(cost === void 0 ? ` \xB7 ${dest}` : ` \xB7 ${dest} \u2014 ${cost}`);
1055
+ }
1056
+ }
1057
+ if (mergeLint !== null) {
1058
+ const mergeMessage = {
1059
+ added: `spliced "${ctx.vars.OXLINT_PRESET_PATH}" into the existing .oxlintrc.json extends`,
1060
+ already: ".oxlintrc.json already extends the shipped preset \u2014 nothing to do",
1061
+ absent: "no existing .oxlintrc.json to merge into (the seeded one already extends the preset)",
1062
+ unreadable: ".oxlintrc.json is not valid JSON \u2014 merge the extends entry by hand",
1063
+ "has-comments": `.oxlintrc.json carries comments; rewriting it would delete them (usually the reason each rule is off). Add "extends": ["${ctx.vars.OXLINT_PRESET_PATH}"] as the first entry by hand.`
1064
+ };
1065
+ console.log(`basalt-ui init: --merge-lint \u2014 ${mergeMessage[mergeLint]}`);
1066
+ }
1067
+ const rootsMessages = rootsNotices(rootsState);
1068
+ for (const line of rootsMessages.log) console.log(`basalt-ui init: ${line}`);
1069
+ for (const line of rootsMessages.error) console.error(`\u26A0 basalt-ui init: ${line}`);
1070
+ if (seededLintScript) {
1071
+ console.log(`basalt-ui init: added the "lint:basalt" script (${lintScript}) to package.json.`);
1072
+ }
549
1073
  if (!placement.isPackageRepoRoot) {
550
1074
  console.log(
551
- `basalt-ui init: skipped lefthook.yml (this package is not the repo root \u2014 repo root detected at ${placement.repoRoot}) \u2014 lefthook only reads config at the repo root; extend node_modules/basalt-ui/configs/lefthook.yml from your root lefthook.yml instead.`
1075
+ `basalt-ui init: skipped lefthook.yml (this package is not the repo root \u2014 repo root detected at ${placement.repoRoot}) \u2014 lefthook only reads config at the repo root; extend ${ctx.vars.LEFTHOOK_PRESET_PATH} from your root lefthook.yml instead, and give its check-theme command \`env: { BASALT_CWD: ${relativePosix(placement.repoRoot, cwd)} }\` so the guard runs where your basalt config actually lives.`
552
1076
  );
553
1077
  console.log(
554
- `basalt-ui init: skipped .github/workflows/check.yml (this package is not the repo root \u2014 repo root detected at ${placement.repoRoot}) \u2014 GitHub Actions only reads .github/ at the repo root; extend node_modules/basalt-ui/configs/check.yml from your root CI workflow instead.`
1078
+ `basalt-ui init: skipped .github/workflows/check.yml (this package is not the repo root \u2014 repo root detected at ${placement.repoRoot}) \u2014 GitHub Actions only reads .github/ at the repo root; copy the steps from ${shippedAssetPath(install, placement.repoRoot, "configs/check.yml")} into your root CI workflow instead.`
555
1079
  );
556
1080
  }
557
1081
  if (placement.relocatedQueryClient !== null) {
@@ -581,12 +1105,34 @@ Theme guard hook: add to .claude/settings.json \u2192 hooks.PreToolUse to catch
581
1105
  ]
582
1106
  }`
583
1107
  );
1108
+ const presetPlugins = readOxlintPresetPlugins(ctx.pkgRoot);
1109
+ if (presetPlugins.length > 0) {
1110
+ console.log(
1111
+ `
1112
+ Lint debt: adopting the shipped oxlint preset on an EXISTING app is not a no-op \u2014 it turns on ${presetPlugins.length} plugins (${presetPlugins.join(", ")}) plus the basalt/* design rules, on code never linted against them. Run \`oxlint .\` now and triage the count before your next commit; turn a rule off in your own .oxlintrc.json with a written reason rather than blanket-disabling a plugin.`
1113
+ );
1114
+ }
1115
+ console.log(
1116
+ "Activate the hooks: `lefthook install` \u2014 the seeded lefthook.yml is inert until the git hooks are written, and looks configured either way."
1117
+ );
584
1118
  console.log(
585
- "\nFirst run: run `basalt-ui check-theme` next, then tune the per-rule `basalt.*` config keys in package.json for anything that fires \u2014 do not mass-`theme-allow` findings."
1119
+ `
1120
+ First run: run \`${ctx.vars.BASALT_BIN} check-theme\` next (or \`bun run lint:basalt\`), then tune the per-rule \`basalt.*\` config keys in package.json for anything that fires \u2014 do not mass-\`theme-allow\` findings. Then \`basalt-ui doctor\` to confirm the wiring actually took.`
586
1121
  );
587
1122
  return 0;
588
1123
  }
1124
+ function readOxlintPresetPlugins(pkgRoot) {
1125
+ const raw = readSource(pkgRoot, "configs/oxlint.json");
1126
+ if (raw === null) return [];
1127
+ try {
1128
+ const plugins = JSON.parse(raw).plugins;
1129
+ return isStringArray(plugins) ? plugins : [];
1130
+ } catch {
1131
+ return [];
1132
+ }
1133
+ }
589
1134
  function sync(opts = {}, cwd = process.cwd()) {
1135
+ const rootsState = reconcileRoots(cwd, { write: opts.check !== true });
590
1136
  const ctx = renderContext(cwd);
591
1137
  const manifest = readManifest(cwd);
592
1138
  if (!opts.check) migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest);
@@ -642,6 +1188,23 @@ function sync(opts = {}, cwd = process.cwd()) {
642
1188
  `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.`
643
1189
  );
644
1190
  }
1191
+ const rootsMessages = rootsNotices(rootsState);
1192
+ for (const line of rootsMessages.log) console.log(`basalt-ui sync: ${line}`);
1193
+ for (const line of rootsMessages.error) console.error(`\u26A0 basalt-ui sync: ${line}`);
1194
+ const install = findBasaltInstall(cwd);
1195
+ const lefthookSeam = inspectLefthookSeam(placement.repoRoot);
1196
+ if (lefthookSeam.kind === "broken") {
1197
+ console.error(
1198
+ `\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")}".`
1199
+ );
1200
+ }
1201
+ const oxlintrcRaw = readIfExists(resolve(cwd, ".oxlintrc.json"));
1202
+ const oxlintEntry = oxlintrcRaw === null ? null : basaltPresetEntry(parseJsonc(oxlintrcRaw)?.["extends"]);
1203
+ if (oxlintEntry !== null && !existsSync(resolve(cwd, oxlintEntry))) {
1204
+ console.error(
1205
+ `\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")}".`
1206
+ );
1207
+ }
645
1208
  if (opts.check) {
646
1209
  if (driftLines.length > 0) {
647
1210
  console.error("basalt-ui sync --check: locally-drifted managed files:");
@@ -725,7 +1288,7 @@ function checkCoverage() {
725
1288
  for (const exportKey of pkgExports) {
726
1289
  if (exportKey === "." || exportKey === "./styles.css" || exportKey.startsWith("./configs/"))
727
1290
  continue;
728
- if (!(exportKey in SURFACES)) {
1291
+ if (!Object.hasOwn(SURFACES, exportKey)) {
729
1292
  failures.push(`package.json exports key '${exportKey}' has no matching SURFACES entry`);
730
1293
  }
731
1294
  }
@@ -880,9 +1443,27 @@ function resolveAiMajorSkewReason(cfg) {
880
1443
  }
881
1444
  return { reason: null, presentButInvalid: true };
882
1445
  }
883
- function doctor(cwd = process.cwd()) {
1446
+ function doctor(invocationCwd = process.cwd(), flags = []) {
1447
+ if (conflictingProfileFlags(flags)) {
1448
+ console.error("basalt-ui doctor: --tokens-only and --framework are alternatives \u2014 pass one.");
1449
+ return 1;
1450
+ }
1451
+ const project = resolveProjectDir(invocationCwd);
1452
+ if (project.ambiguous !== null) {
1453
+ console.error(
1454
+ `
1455
+ basalt-ui doctor \u2014 ${invocationCwd}
1456
+
1457
+ \u2716 no basalt config at ${invocationCwd}, and ${project.ambiguous.length} workspace packages carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(", ")}) \u2014 run doctor from one of them, or set BASALT_CWD to pick.
1458
+
1459
+ 1 hard failure(s), 0 warning(s).
1460
+ `
1461
+ );
1462
+ return 1;
1463
+ }
1464
+ const cwd = project.dir;
884
1465
  const cfg = readBasaltConfig(cwd);
885
- const result = { hardFailures: 0, warnings: 0 };
1466
+ const result = { hardFailures: 0, warnings: 0, skipped: 0 };
886
1467
  const lines = [`
887
1468
  basalt-ui doctor \u2014 ${cwd}
888
1469
  `];
@@ -893,26 +1474,59 @@ basalt-ui doctor \u2014 ${cwd}
893
1474
  lines.push(` \u26A0 ${msg}`);
894
1475
  result.warnings++;
895
1476
  }
1477
+ function skip(msg) {
1478
+ lines.push(` \u2298 SKIPPED \u2014 ${msg}`);
1479
+ result.skipped++;
1480
+ }
896
1481
  function fail(msg) {
897
1482
  lines.push(` \u2716 ${msg}`);
898
1483
  result.hardFailures++;
899
1484
  }
1485
+ if (project.relocatedFrom !== null) {
1486
+ lines.push(
1487
+ ` \u2192 no basalt config at ${project.relocatedFrom} \u2014 reporting on ${relativePosix(project.relocatedFrom, cwd)}, where it lives.
1488
+ `
1489
+ );
1490
+ }
900
1491
  const manifestAbs = resolve(cwd, MANIFEST_PATH);
901
1492
  const manifestExists = existsSync(manifestAbs);
902
- if (manifestExists) {
1493
+ const profile = inferredProfile(cwd, cfg, flags);
1494
+ if (profile === "tokens-only") {
1495
+ lines.push(
1496
+ " profile: tokens-only \u2014 checking the token layer only. The scaffold checks below are not\n applicable; `basalt-ui init` is NOT the fix here, it places a Mantine doctrine you have no\n use for. Pass --framework to force the full profile.\n"
1497
+ );
1498
+ if (cfg.profile === void 0) {
1499
+ lines.push(
1500
+ ' \u2192 inferred (no .basalt/manifest.json and no @mantine/core in this workspace). Write it\n down as "basalt": { "profile": "tokens-only" } in package.json \u2014 check-theme does not\n infer it, so today it still reports the Mantine-only kinds against this repo.\n'
1501
+ );
1502
+ }
1503
+ }
1504
+ if (profile === "tokens-only") {
1505
+ pass(`${MANIFEST_PATH}: n/a \u2014 a tokens-only consumer has no scaffold to reconcile`);
1506
+ } else if (manifestExists) {
903
1507
  pass(`${MANIFEST_PATH} exists`);
904
1508
  } else {
905
1509
  fail(`${MANIFEST_PATH} missing \u2014 run \`basalt-ui init\` to scaffold the consumer repo`);
906
1510
  }
907
- const consumerPkgRaw = readIfExists(resolve(cwd, "node_modules", "basalt-ui", "package.json"));
908
- let installedVersion = null;
909
- if (consumerPkgRaw !== null) {
910
- try {
911
- const consumerPkg = JSON.parse(consumerPkgRaw);
912
- installedVersion = consumerPkg.version ?? null;
913
- } catch {
914
- warn("could not parse node_modules/basalt-ui/package.json \u2014 version checks skipped");
915
- }
1511
+ const install = findBasaltInstall(cwd);
1512
+ const installedVersion = install.version;
1513
+ if (install.dir === null) {
1514
+ fail(
1515
+ `basalt-ui does not resolve from ${cwd}, any directory above it, or any workspace package \u2014 so every version check below is unrunnable, \`extends: [<preset>]\` cannot resolve, and \`bunx basalt-ui\` will silently fetch a DIFFERENT copy from npm instead of your pinned one. Add basalt-ui as a dependency here (or at the repo root) and re-run.`
1516
+ );
1517
+ } else if (install.how === "cwd") {
1518
+ pass(
1519
+ `basalt-ui resolves at ./node_modules/basalt-ui (${installedVersion ?? "unknown version"})`
1520
+ );
1521
+ } else {
1522
+ pass(
1523
+ `basalt-ui resolves at ${relativePosix(cwd, install.dir)} (${installedVersion ?? "unknown version"}, found via the ${install.how}) \u2014 seeded \`extends\` paths and CI steps must use that path, not ./node_modules/basalt-ui.`
1524
+ );
1525
+ }
1526
+ if (profile === "framework" && manifestExists && installedVersion === null) {
1527
+ skip(
1528
+ "installed-vs-manifest version \u2014 basalt-ui did not resolve, so there is nothing to compare"
1529
+ );
916
1530
  }
917
1531
  if (manifestExists && installedVersion !== null) {
918
1532
  const manifestVersion = readManifest(cwd).basaltVersion;
@@ -930,11 +1544,17 @@ basalt-ui doctor \u2014 ${cwd}
930
1544
  }
931
1545
  const cliVersion = readFrameworkVersion(packageRoot());
932
1546
  const cliIsTheInstall = installedVersion === null || installedVersion === cliVersion;
933
- if (manifestExists && !cliIsTheInstall) {
1547
+ if (profile === "tokens-only") {
1548
+ pass("spacing scale: n/a \u2014 a tokens-only consumer renders no basalt Mantine theme");
1549
+ } else if (!manifestExists) {
1550
+ skip(
1551
+ `spacing scale \u2014 no ${MANIFEST_PATH} records the scale this app last synced with, so there is nothing to compare against`
1552
+ );
1553
+ } else if (!cliIsTheInstall) {
934
1554
  pass(
935
1555
  `spacing scale not compared \u2014 this CLI (${cliVersion}) is not the installed basalt-ui (${installedVersion}), so its scale is not the one the app renders with`
936
1556
  );
937
- } else if (manifestExists) {
1557
+ } else {
938
1558
  const recorded = readManifest(cwd).spacingScale;
939
1559
  const current = deriveSpacing(0).scale;
940
1560
  if (recorded === void 0) {
@@ -951,17 +1571,84 @@ basalt-ui doctor \u2014 ${cwd}
951
1571
  }
952
1572
  }
953
1573
  }
954
- if (installedVersion !== null) {
955
- if (installedVersion !== cliVersion) {
956
- warn(
957
- `running CLI version (${cliVersion}) differs from the installed basalt-ui version in node_modules (${installedVersion}) \u2014 likely a stale \`bunx basalt-ui\` fetch from npm; add basalt-ui as a root devDependency so the bin hoists from your workspace.`
1574
+ if (installedVersion === null) {
1575
+ skip("CLI-vs-installed version \u2014 basalt-ui did not resolve, so there is no installed version");
1576
+ } else if (installedVersion !== cliVersion) {
1577
+ warn(
1578
+ `running CLI version (${cliVersion}) differs from the installed basalt-ui version at ${install.dir === null ? "node_modules" : relativePosix(cwd, install.dir)} (${installedVersion}) \u2014 likely a stale \`bunx basalt-ui\` fetch from npm; run the local bin (\`node_modules/.bin/basalt-ui\`, or a package.json script) so the pinned copy is the one that runs.`
1579
+ );
1580
+ } else {
1581
+ pass(`CLI version (${cliVersion}) matches the installed basalt-ui`);
1582
+ }
1583
+ if (profile === "tokens-only") {
1584
+ pass("guard-scan: n/a \u2014 the theme guard is a Mantine-app check");
1585
+ } else {
1586
+ const scanned = scannableFiles(cwd, cfg);
1587
+ if (scanned.length === 0) {
1588
+ fail(
1589
+ `guard-scan: check-theme would scan 0 files \u2014 the roots (${resolveRoots(cfg).join(", ")}) match nothing under ${cwd}, so the palette guard is a no-op that still exits green in every gate that does not run it. Set "basalt": { "roots": [...] } in package.json.`
1590
+ );
1591
+ } else {
1592
+ pass(
1593
+ `guard-scan: check-theme covers ${scanned.length} file(s) under ${resolveRoots(cfg).join(", ")}`
1594
+ );
1595
+ }
1596
+ }
1597
+ if (profile === "tokens-only") {
1598
+ pass("oxlint-preset: n/a \u2014 the shipped preset is a Mantine/React preset");
1599
+ } else {
1600
+ const oxlintrcRaw = readIfExists(resolve(cwd, ".oxlintrc.json"));
1601
+ if (oxlintrcRaw === null) {
1602
+ fail(
1603
+ ".oxlintrc.json missing \u2014 the shipped oxlint preset (the basalt/* design rules) is not active. Run `basalt-ui init` to seed it."
958
1604
  );
959
1605
  } else {
960
- pass(`CLI version (${cliVersion}) matches the installed basalt-ui in node_modules`);
1606
+ const parsed = parseJsonc(oxlintrcRaw);
1607
+ if (parsed === null) {
1608
+ skip(
1609
+ ".oxlintrc.json does not parse as JSON/JSONC \u2014 cannot tell whether it extends the preset"
1610
+ );
1611
+ } else {
1612
+ const entry = basaltPresetEntry(parsed["extends"]);
1613
+ const correct = shippedAssetPath(install, cwd, "configs/oxlint.json");
1614
+ if (entry === null) {
1615
+ fail(
1616
+ `.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.`
1617
+ );
1618
+ } else if (!existsSync(resolve(cwd, entry)) && entry === correct && install.dir !== null) {
1619
+ fail(
1620
+ `.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.`
1621
+ );
1622
+ } else if (!existsSync(resolve(cwd, entry))) {
1623
+ fail(
1624
+ `.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.`
1625
+ );
1626
+ } else {
1627
+ pass(`.oxlintrc.json extends the shipped basalt-ui oxlint preset (${entry})`);
1628
+ }
1629
+ }
961
1630
  }
962
1631
  }
1632
+ const repoRoot = findRepoRoot(cwd);
1633
+ const lefthookSeam = profile === "tokens-only" ? { kind: "n/a" } : inspectLefthookSeam(repoRoot);
1634
+ const lefthookCorrect = shippedAssetPath(install, repoRoot, "configs/lefthook.yml");
1635
+ if (lefthookSeam.kind === "n/a") {
1636
+ pass("lefthook-preset: n/a \u2014 a tokens-only consumer wires its own hooks");
1637
+ } else if (lefthookSeam.kind === "no-file") {
1638
+ pass(`lefthook-preset: n/a \u2014 no lefthook config at ${repoRoot}`);
1639
+ } else if (lefthookSeam.kind === "unwired") {
1640
+ warn(
1641
+ `${lefthookSeam.file} does not extend the shipped lefthook preset \u2014 the oxlint / oxfmt / check-theme pre-commit jobs are not wired. Add "extends: [${lefthookCorrect}]" to it.`
1642
+ );
1643
+ } else if (lefthookSeam.kind === "broken") {
1644
+ fail(
1645
+ `${lefthookSeam.file} extends "${lefthookSeam.entry}", which does not exist (${resolve(repoRoot, lefthookSeam.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}".`
1646
+ );
1647
+ } else {
1648
+ pass(`${lefthookSeam.file} extends the shipped lefthook preset (${lefthookSeam.entry})`);
1649
+ }
963
1650
  const publicDir = resolve(cwd, "public");
964
- if (existsSync(publicDir)) {
1651
+ if (profile === "framework" && existsSync(publicDir)) {
965
1652
  const iconFiles = [
966
1653
  "favicon.ico",
967
1654
  "favicon.svg",
@@ -1013,15 +1700,17 @@ basalt-ui doctor \u2014 ${cwd}
1013
1700
  pass("ai-major-parity: no workspace package declares the ai dependency \u2014 nothing to compare");
1014
1701
  }
1015
1702
  lines.push("");
1016
- if (result.hardFailures === 0 && result.warnings === 0) {
1703
+ if (result.hardFailures === 0 && result.warnings === 0 && result.skipped === 0) {
1017
1704
  lines.push("All checks passed.");
1018
1705
  } else {
1019
- if (result.hardFailures > 0)
1020
- lines.push(`${result.hardFailures} hard failure(s), ${result.warnings} warning(s).`);
1021
- else lines.push(`${result.warnings} warning(s).`);
1706
+ const parts = [];
1707
+ if (result.hardFailures > 0) parts.push(`${result.hardFailures} hard failure(s)`);
1708
+ if (result.skipped > 0) parts.push(`${result.skipped} check(s) SKIPPED (could not run)`);
1709
+ parts.push(`${result.warnings} warning(s)`);
1710
+ lines.push(`${parts.join(", ")}.`);
1022
1711
  }
1023
1712
  lines.push("");
1024
- if (result.hardFailures > 0) {
1713
+ if (result.hardFailures > 0 || result.skipped > 0) {
1025
1714
  console.error(lines.join("\n"));
1026
1715
  return 1;
1027
1716
  }
@@ -1101,8 +1790,65 @@ function flagValue(flags, name) {
1101
1790
  const value = flags[i + 1];
1102
1791
  return value === void 0 || value.startsWith("--") ? void 0 : value;
1103
1792
  }
1793
+ function generatedHeader(version, command, flags) {
1794
+ const shown = flags.filter((f) => f !== "--check");
1795
+ const invocation = shown.length === 0 ? command : `${command} ${shown.join(" ")}`;
1796
+ return `${GENERATED_HEADER_LINE}
1797
+ /* basalt-ui ${version} \u2014 \`basalt-ui ${invocation}\` */
1798
+ `;
1799
+ }
1800
+ function normalizeColorFunctions(css) {
1801
+ return css.replace(/\b(rgba?|hsla?)\(([^()]*)\)/g, (_match, fn, args) => {
1802
+ const parts = args.split(",").map((part) => normalizeCssNumber(part.trim()));
1803
+ return `${fn}(${parts.join(", ")})`;
1804
+ });
1805
+ }
1806
+ function normalizeCssNumber(arg) {
1807
+ if (!/^\d+\.\d+$/.test(arg)) return arg;
1808
+ return arg.replace(/\.?0+$/, "");
1809
+ }
1810
+ const SCHEME_CLASS_SENTINEL = "data-basalt-scheme-class";
1811
+ function emitGeneratedCss(content, flags, cwd, command) {
1812
+ const out = flagValue(flags, "--out");
1813
+ const check = flags.includes("--check");
1814
+ if (out === void 0) {
1815
+ if (check) {
1816
+ console.error(
1817
+ `${command}: --check needs --out <path> \u2014 there is nothing to compare stdout to.`
1818
+ );
1819
+ return 1;
1820
+ }
1821
+ process.stdout.write(content);
1822
+ return 0;
1823
+ }
1824
+ const target = isAbsolute(out) ? out : resolve(cwd, out);
1825
+ const shown = relative(cwd, target) || target;
1826
+ if (check) {
1827
+ const onDisk = readIfExists(target);
1828
+ if (onDisk === null) {
1829
+ console.error(
1830
+ `\u2716 ${command} --check: ${shown} does not exist \u2014 run without --check to write it.`
1831
+ );
1832
+ return 1;
1833
+ }
1834
+ if (onDisk === content) {
1835
+ console.log(`\u2713 ${command} --check: ${shown} is up to date.`);
1836
+ return 0;
1837
+ }
1838
+ console.error(
1839
+ `\u2716 ${command} --check: ${shown} differs from what \`basalt-ui ${command}\` emits today (on disk ${onDisk.split("\n").length} lines, emitted ${content.split("\n").length}) \u2014 re-run the same command without --check and commit the result.`
1840
+ );
1841
+ return 1;
1842
+ }
1843
+ mkdirSync(dirname(target), { recursive: true });
1844
+ writeFileSync(target, content);
1845
+ console.log(`wrote ${content.split("\n").length} lines \u2192 ${shown}`);
1846
+ return 0;
1847
+ }
1104
1848
  function tokensCss(flags, cwd = process.cwd()) {
1105
1849
  const attribute = flagValue(flags, "--selector-attribute");
1850
+ const schemeClass = flagValue(flags, "--selector-class");
1851
+ const lightClass = flagValue(flags, "--light-class");
1106
1852
  const darkValue = flagValue(flags, "--dark-value");
1107
1853
  const lightValue = flagValue(flags, "--light-value");
1108
1854
  const defaultScheme = flagValue(flags, "--default-scheme");
@@ -1117,12 +1863,31 @@ function tokensCss(flags, cwd = process.cwd()) {
1117
1863
  console.error(`tokens:css: --only must be core or all (got '${only}')`);
1118
1864
  return 1;
1119
1865
  }
1866
+ if (schemeClass !== void 0 && attribute !== void 0) {
1867
+ console.error(
1868
+ "tokens:css: --selector-class and --selector-attribute are alternatives \u2014 pass one."
1869
+ );
1870
+ return 1;
1871
+ }
1872
+ if (schemeClass !== void 0 && /[^\w-]/.test(schemeClass)) {
1873
+ console.error(
1874
+ `tokens:css: --selector-class must be a plain CSS class name (got '${schemeClass}')`
1875
+ );
1876
+ return 1;
1877
+ }
1878
+ if (lightClass !== void 0 && /[^\w-]/.test(lightClass)) {
1879
+ console.error(`tokens:css: --light-class must be a plain CSS class name (got '${lightClass}')`);
1880
+ return 1;
1881
+ }
1882
+ const resolvedDarkValue = schemeClass !== void 0 ? schemeClass : darkValue;
1883
+ const resolvedLightValue = schemeClass !== void 0 ? lightClass ?? "light" : lightValue;
1884
+ const resolvedAttribute = schemeClass !== void 0 ? SCHEME_CLASS_SENTINEL : attribute;
1120
1885
  const scheme = {
1121
- ...attribute === void 0 ? {} : { attribute },
1122
- ...darkValue === void 0 ? {} : { darkValue },
1123
- ...lightValue === void 0 ? {} : { lightValue }
1886
+ ...resolvedAttribute === void 0 ? {} : { attribute: resolvedAttribute },
1887
+ ...resolvedDarkValue === void 0 ? {} : { darkValue: resolvedDarkValue },
1888
+ ...resolvedLightValue === void 0 ? {} : { lightValue: resolvedLightValue }
1124
1889
  };
1125
- const css = buildPaletteCss({
1890
+ let css = buildPaletteCss({
1126
1891
  ...Object.keys(scheme).length === 0 ? {} : { scheme },
1127
1892
  ...defaultScheme === void 0 ? {} : { defaultScheme },
1128
1893
  ...flags.includes("--media-fallback") ? { mediaFallback: true } : {},
@@ -1133,17 +1898,44 @@ function tokensCss(flags, cwd = process.cwd()) {
1133
1898
  // are, which is the one thing this command is documented never to allow.
1134
1899
  ...flags.includes("--no-legacy-aliases") ? { legacyAliases: false } : {}
1135
1900
  });
1136
- const out = flagValue(flags, "--out");
1137
- if (out === void 0) {
1138
- process.stdout.write(`${css}
1139
- `);
1140
- return 0;
1901
+ if (schemeClass !== void 0) {
1902
+ css = css.replace(
1903
+ new RegExp(`\\[${SCHEME_CLASS_SENTINEL}='([\\w-]+)'\\]`, "g"),
1904
+ (_match, value) => `.${value}`
1905
+ );
1141
1906
  }
1142
- const target = isAbsolute(out) ? out : resolve(cwd, out);
1143
- mkdirSync(dirname(target), { recursive: true });
1144
- writeFileSync(target, css);
1145
- console.log(`wrote ${css.split("\n").length} lines \u2192 ${relative(cwd, target) || target}`);
1146
- return 0;
1907
+ const version = readFrameworkVersion(packageRoot());
1908
+ const content = `${generatedHeader(version, "tokens:css", flags)}${normalizeColorFunctions(css)}
1909
+ `;
1910
+ return emitGeneratedCss(content, flags, cwd, "tokens:css");
1911
+ }
1912
+ function readShippedFontDecls(pkgRoot) {
1913
+ const css = readSource(pkgRoot, "dist/styles.css") ?? readSource(pkgRoot, "src/styles.css");
1914
+ if (css === null) return [];
1915
+ const decls = [];
1916
+ for (const match of css.matchAll(/(--basalt-font[\w-]*)\s*:\s*([^;]+);/g)) {
1917
+ decls.push({
1918
+ name: match[1],
1919
+ value: match[2].replace(/\s+/g, " ").trim()
1920
+ });
1921
+ }
1922
+ return decls;
1923
+ }
1924
+ function fontsCss(flags, cwd = process.cwd()) {
1925
+ const pkgRoot = packageRoot();
1926
+ const decls = readShippedFontDecls(pkgRoot);
1927
+ if (decls.length === 0) {
1928
+ console.error(
1929
+ "fonts:css: could not read the shipped font stacks from styles.css \u2014 this is a packaging bug in basalt-ui, not a config error."
1930
+ );
1931
+ return 1;
1932
+ }
1933
+ const body = `:root {
1934
+ ${decls.map((d) => ` ${d.name}: ${d.value};`).join("\n")}
1935
+ }
1936
+ `;
1937
+ const header = generatedHeader(readFrameworkVersion(pkgRoot), "fonts:css", flags);
1938
+ return emitGeneratedCss(`${header}${body}`, flags, cwd, "fonts:css");
1147
1939
  }
1148
1940
  async function guardHook(cwd = process.cwd()) {
1149
1941
  let raw;
@@ -1189,8 +1981,11 @@ async function guardHook(cwd = process.cwd()) {
1189
1981
  rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
1190
1982
  sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
1191
1983
  allowComment: "theme-allow",
1192
- exemptRules: cfg.exemptRules ?? DEFAULT_GUARD_CONFIG.exemptRules,
1193
- severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity
1984
+ exemptRules: resolveExemptRules(cfg),
1985
+ severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
1986
+ // Same detection check-theme uses: the hook must never block an edit over advice the app
1987
+ // cannot take ("use @mantine/core's Select") in a repo with no Mantine.
1988
+ ...declaredProfile(cfg, []) === "tokens-only" ? { profile: "tokens-only" } : {}
1194
1989
  };
1195
1990
  const roots = cfg.roots ?? DEFAULT_ROOTS;
1196
1991
  const exempt = new Set(cfg.exempt ?? defaultExempt(cfg));
@@ -1222,7 +2017,7 @@ async function guardHook(cwd = process.cwd()) {
1222
2017
  }
1223
2018
  return 0;
1224
2019
  }
1225
- const USAGE = "Usage: basalt-ui <init [--with-router] [--with-query] | sync [--force] [--check] | check-theme | check-coverage | info [--json] | doctor | guard-hook | tokens:css | help>\n\ntokens:css [--out <path>] [--selector-attribute <attr>] [--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 --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\nEvery subcommand accepts --help / -h to print this message and exit without running.";
2020
+ 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.";
1226
2021
  function run(argv, cwd = process.cwd()) {
1227
2022
  const [cmd, ...flags] = argv;
1228
2023
  if (cmd === "help" || cmd === "--help" || cmd === "-h" || flags.includes("--help") || flags.includes("-h")) {
@@ -1233,22 +2028,25 @@ function run(argv, cwd = process.cwd()) {
1233
2028
  case "init":
1234
2029
  return init(cwd, {
1235
2030
  withRouter: flags.includes("--with-router"),
1236
- withQuery: flags.includes("--with-query")
2031
+ withQuery: flags.includes("--with-query"),
2032
+ mergeLint: flags.includes("--merge-lint")
1237
2033
  });
1238
2034
  case "sync":
1239
2035
  return sync({ force: flags.includes("--force"), check: flags.includes("--check") }, cwd);
1240
2036
  case "check-theme":
1241
- return checkTheme(cwd);
2037
+ return checkTheme(cwd, flags);
1242
2038
  case "check-coverage":
1243
2039
  return checkCoverage();
1244
2040
  case "info":
1245
2041
  return info(flags);
1246
2042
  case "doctor":
1247
- return doctor(cwd);
2043
+ return doctor(cwd, flags);
1248
2044
  case "guard-hook":
1249
2045
  return guardHook(cwd);
1250
2046
  case "tokens:css":
1251
2047
  return tokensCss(flags, cwd);
2048
+ case "fonts:css":
2049
+ return fontsCss(flags, cwd);
1252
2050
  default:
1253
2051
  console.error(USAGE);
1254
2052
  return 1;
@@ -1261,10 +2059,12 @@ export {
1261
2059
  checkCoverage,
1262
2060
  checkTheme,
1263
2061
  doctor,
2062
+ fontsCss,
1264
2063
  guardHook,
1265
2064
  info,
1266
2065
  init,
1267
2066
  managedFiles,
2067
+ normalizeColorFunctions,
1268
2068
  normalizeForLedger,
1269
2069
  run,
1270
2070
  sync,