basalt-ui 1.19.0 → 1.20.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 (83) hide show
  1. package/AGENTS.md +15 -1
  2. package/CHANGELOG.md +27 -0
  3. package/MIGRATING.md +228 -0
  4. package/README.md +53 -9
  5. package/agent/rules/basalt-app.md +43 -0
  6. package/agent/rules/basalt-charts.md +26 -14
  7. package/agent/rules/basalt-mantine.md +25 -10
  8. package/agent/rules/basalt-router.md +22 -10
  9. package/agent/rules/basalt-tokens.md +75 -6
  10. package/agent/skills/basalt-app/SKILL.md +42 -6
  11. package/configs/check.yml +11 -4
  12. package/configs/lefthook.yml +21 -2
  13. package/configs/oxlint-plugin.js +515 -25
  14. package/configs/oxlint-plugin.test.ts +383 -10
  15. package/configs/oxlint.json +10 -1
  16. package/dist/agent/stick-to-bottom.js +1 -1
  17. package/dist/agent-chat/thread-detail-panel.js +1 -1
  18. package/dist/agent-chat/thread-message.js +2 -2
  19. package/dist/agent-chat/tool-chip.js +1 -1
  20. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  21. package/dist/charts/kinds/Donut.js +1 -1
  22. package/dist/charts/primitives/ChartLegend.js +2 -2
  23. package/dist/charts/primitives/ChartTooltip.js +1 -1
  24. package/dist/cli/index.d.ts +107 -15
  25. package/dist/cli/index.d.ts.map +1 -1
  26. package/dist/cli/index.js +610 -72
  27. package/dist/cli/toolchain-wiring.test.d.ts +2 -0
  28. package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
  29. package/dist/dashboard/delta-badge.js +2 -2
  30. package/dist/dashboard/stat-card.js +1 -1
  31. package/dist/data/virtual-list.js +5 -5
  32. package/dist/guard/index.d.ts +56 -2
  33. package/dist/guard/index.d.ts.map +1 -1
  34. package/dist/guard/index.js +326 -59
  35. package/dist/guard/types.d.ts +25 -5
  36. package/dist/guard/types.d.ts.map +1 -1
  37. package/dist/register.d.ts +25 -0
  38. package/dist/register.d.ts.map +1 -1
  39. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  40. package/dist/shell/app-mobile-nav.js +14 -14
  41. package/dist/shell/app-mobile-nav.module.css +44 -11
  42. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -1
  43. package/dist/surfaces.d.ts +1 -1
  44. package/dist/surfaces.js +1 -1
  45. package/dist/tokens/index.d.ts +3 -3
  46. package/dist/tokens/index.d.ts.map +1 -1
  47. package/dist/tokens/index.js +2 -0
  48. package/dist/tokens/palette.d.ts +35 -27
  49. package/dist/tokens/palette.d.ts.map +1 -1
  50. package/dist/tokens/palette.js +24 -18
  51. package/dist/tokens.css +19 -17
  52. package/dist/vite-color-mix.js +2 -2
  53. package/llms.txt +3 -3
  54. package/package.json +4 -3
  55. package/src/agent/stick-to-bottom.tsx +1 -1
  56. package/src/agent-chat/thread-detail-panel.tsx +1 -1
  57. package/src/agent-chat/thread-message.tsx +3 -3
  58. package/src/agent-chat/tool-chip.tsx +1 -1
  59. package/src/charts/kinds/Donut.tsx +2 -1
  60. package/src/charts/primitives/ChartLegend.tsx +2 -2
  61. package/src/charts/primitives/ChartTooltip.tsx +1 -1
  62. package/src/cli/index.ts +1070 -99
  63. package/src/cli/init-sync.test.ts +19 -3
  64. package/src/cli/tokens-css.test.ts +27 -14
  65. package/src/cli/toolchain-wiring.test.ts +694 -0
  66. package/src/dashboard/delta-badge.tsx +3 -3
  67. package/src/dashboard/stat-card.tsx +1 -1
  68. package/src/data/virtual-list.tsx +8 -8
  69. package/src/guard/check-source.test.ts +663 -11
  70. package/src/guard/index.ts +727 -94
  71. package/src/guard/types.ts +29 -4
  72. package/src/register.ts +32 -0
  73. package/src/shell/app-mobile-nav.module.css +44 -11
  74. package/src/shell/app-mobile-nav.module.css.d.ts +1 -1
  75. package/src/shell/app-mobile-nav.test.tsx +34 -0
  76. package/src/shell/app-mobile-nav.tsx +16 -5
  77. package/src/surfaces.ts +1 -1
  78. package/src/theme/density-relations.test.ts +8 -5
  79. package/src/theme/spacing.test.ts +2 -0
  80. package/src/theme-toggle/index.tsx +2 -2
  81. package/src/tokens/index.ts +5 -3
  82. package/src/tokens/palette.ts +24 -18
  83. package/src/vite-color-mix.ts +2 -2
package/dist/cli/index.js CHANGED
@@ -10,7 +10,13 @@ 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
+ GUARD_RULES,
18
+ TOKENS_ONLY_DISABLED_KINDS
19
+ } from "../guard/index.js";
14
20
  import { evaluateGuardHook } from "../guard/guard-hook.js";
15
21
  import { RULE_NAMES, SKILL_NAMES, SURFACES, TOKEN_LAYER_BOUNDARY_SURFACES } from "../surfaces.js";
16
22
  import { buildPaletteCss, deriveSpacing } from "../tokens/index.js";
@@ -61,6 +67,7 @@ function readBasaltConfig(cwd) {
61
67
  return {};
62
68
  }
63
69
  }
70
+ const SCANNABLE_EXT = /\.(?:tsx?|css|html?|webmanifest)$/;
64
71
  function walkSourceFiles(dir, out = []) {
65
72
  let entries;
66
73
  try {
@@ -78,7 +85,7 @@ function walkSourceFiles(dir, out = []) {
78
85
  continue;
79
86
  }
80
87
  if (isDir) walkSourceFiles(abs, out);
81
- else if (/\.(?:tsx?|css)$/.test(name)) out.push(abs);
88
+ else if (SCANNABLE_EXT.test(name)) out.push(abs);
82
89
  }
83
90
  return out;
84
91
  }
@@ -101,10 +108,145 @@ const GUARD_KIND_EXPLANATIONS = {
101
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.",
102
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."
103
110
  };
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,
@@ -124,20 +266,15 @@ function checkTheme(cwd = process.cwd()) {
124
266
  sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
125
267
  allowComment: "theme-allow",
126
268
  exemptRules: cfg.exemptRules ?? DEFAULT_GUARD_CONFIG.exemptRules,
127
- severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity
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.`
@@ -230,12 +367,17 @@ function readFrameworkVersion(pkgRoot) {
230
367
  }
231
368
  }
232
369
  function buildTemplateVars(pkgRoot, cwd, cfg) {
370
+ const install = findBasaltInstall(cwd);
371
+ const binDir = install.dir === null ? null : resolve(install.dir, "..", ".bin");
233
372
  return {
234
373
  APP_NAME: readPackageName(cwd),
235
374
  BASALT_VERSION: readFrameworkVersion(pkgRoot),
236
375
  ACCENT_HUE: cfg.accentHue ?? "blue",
237
376
  SERIES_MODULE_PATH: resolveSeriesModulePath(cfg),
238
- ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(" ")
377
+ ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(" "),
378
+ OXLINT_PRESET_PATH: shippedAssetPath(install, cwd, "configs/oxlint.json"),
379
+ LEFTHOOK_PRESET_PATH: shippedAssetPath(install, cwd, "configs/lefthook.yml"),
380
+ BASALT_BIN: binDir === null ? "bunx basalt-ui" : `${relativePosix(cwd, binDir).replace(/^\.\//, "")}/basalt-ui`
239
381
  };
240
382
  }
241
383
  function fillTemplate(tpl, vars) {
@@ -383,7 +525,15 @@ function managedFiles(peers, placement = ROOT_PLACEMENT) {
383
525
  dest: "lefthook.yml",
384
526
  mode: "seed",
385
527
  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"
528
+ render: (ctx) => `# Seeded by basalt-ui init \u2014 you own this file; sync never touches it again.
529
+ # The extends target supplies oxlint + oxfmt + check-theme pre-commit jobs and
530
+ # auto-updates with the basalt-ui package. Add your own commands alongside.
531
+ # The path is RESOLVED from where basalt-ui actually installed, not assumed at the
532
+ # repo root \u2014 under an isolated linker it lives beside the package that depends on it.
533
+ # Run \`lefthook install\` once: the file is inert until the git hooks are written.
534
+ extends:
535
+ - ${ctx.vars.LEFTHOOK_PRESET_PATH.replace(/^\.\//, "")}
536
+ `
387
537
  };
388
538
  const ci = {
389
539
  dest: ".github/workflows/check.yml",
@@ -399,7 +549,10 @@ function managedFiles(peers, placement = ROOT_PLACEMENT) {
399
549
  dest: ".oxlintrc.json",
400
550
  mode: "seed",
401
551
  source: "configs/oxlint.json",
402
- render: () => '{\n "extends": ["./node_modules/basalt-ui/configs/oxlint.json"]\n}\n'
552
+ render: (ctx) => `{
553
+ "extends": ["${ctx.vars.OXLINT_PRESET_PATH}"]
554
+ }
555
+ `
403
556
  };
404
557
  const queryClient = {
405
558
  dest: "src/query-client.ts",
@@ -510,15 +663,142 @@ function renderContext(cwd) {
510
663
  const pkgRoot = packageRoot();
511
664
  return { pkgRoot, vars: buildTemplateVars(pkgRoot, cwd, readBasaltConfig(cwd)) };
512
665
  }
666
+ function patchPackageJson(cwd, mutate) {
667
+ const abs = resolve(cwd, "package.json");
668
+ const raw = readIfExists(abs);
669
+ if (raw === null) return false;
670
+ let pkg;
671
+ try {
672
+ pkg = JSON.parse(raw);
673
+ } catch {
674
+ return false;
675
+ }
676
+ if (!mutate(pkg)) return false;
677
+ const indent = /\n([ \t]+)"/.exec(raw)?.[1] ?? " ";
678
+ writeFileSync(abs, JSON.stringify(pkg, null, indent) + (raw.endsWith("\n") ? "\n" : ""));
679
+ return true;
680
+ }
681
+ function detectRoots(cwd) {
682
+ const workspacePackages = collectWorkspacePackages(cwd).packages.filter((pkg) => pkg.dir !== cwd);
683
+ const withSrc = workspacePackages.filter((pkg) => existsSync(resolve(pkg.dir, "src")));
684
+ const uiPackages = withSrc.filter(
685
+ (pkg) => hasDependency(pkg.dir, "basalt-ui") || hasDependency(pkg.dir, "@mantine/core")
686
+ );
687
+ const chosen = uiPackages.length > 0 ? uiPackages : withSrc;
688
+ if (chosen.length > 0) {
689
+ return chosen.map((pkg) => relativePosix(cwd, resolve(pkg.dir, "src")).replace(/^\.\//, ""));
690
+ }
691
+ return existsSync(resolve(cwd, DEFAULT_ROOT)) ? [DEFAULT_ROOT] : [];
692
+ }
693
+ function stripJsonc(text) {
694
+ let out = "";
695
+ let inString = false;
696
+ let escaped = false;
697
+ let i = 0;
698
+ while (i < text.length) {
699
+ const ch = text[i];
700
+ if (inString) {
701
+ out += ch;
702
+ if (escaped) escaped = false;
703
+ else if (ch === "\\") escaped = true;
704
+ else if (ch === '"') inString = false;
705
+ i += 1;
706
+ continue;
707
+ }
708
+ if (ch === '"') {
709
+ inString = true;
710
+ out += ch;
711
+ i += 1;
712
+ continue;
713
+ }
714
+ if (ch === "/" && text[i + 1] === "/") {
715
+ while (i < text.length && text[i] !== "\n") i += 1;
716
+ continue;
717
+ }
718
+ if (ch === "/" && text[i + 1] === "*") {
719
+ i += 2;
720
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i += 1;
721
+ i += 2;
722
+ continue;
723
+ }
724
+ out += ch;
725
+ i += 1;
726
+ }
727
+ return out.replace(/,(\s*[}\]])/g, "$1");
728
+ }
729
+ function parseJsonc(text) {
730
+ try {
731
+ return JSON.parse(stripJsonc(text));
732
+ } catch {
733
+ return null;
734
+ }
735
+ }
736
+ function extendsBasaltPreset(entries) {
737
+ return Array.isArray(entries) && entries.some(
738
+ (entry) => typeof entry === "string" && entry.endsWith("basalt-ui/configs/oxlint.json")
739
+ );
740
+ }
741
+ function mergeOxlintExtends(cwd, presetPath) {
742
+ const abs = resolve(cwd, ".oxlintrc.json");
743
+ const raw = readIfExists(abs);
744
+ if (raw === null) return "absent";
745
+ const cfg = parseJsonc(raw);
746
+ if (cfg === null) return "unreadable";
747
+ if (extendsBasaltPreset(cfg["extends"])) return "already";
748
+ if (stripJsonc(raw) !== raw) return "has-comments";
749
+ const existing = Array.isArray(cfg["extends"]) ? cfg["extends"].filter((entry) => typeof entry === "string") : [];
750
+ const merged = "extends" in cfg ? {} : { extends: [presetPath] };
751
+ for (const [key, value] of Object.entries(cfg)) {
752
+ merged[key] = key === "extends" ? [presetPath, ...existing] : value;
753
+ }
754
+ const indent = /\n([ \t]+)"/.exec(raw)?.[1] ?? " ";
755
+ writeFileSync(abs, JSON.stringify(merged, null, indent) + (raw.endsWith("\n") ? "\n" : ""));
756
+ return "added";
757
+ }
758
+ const KEPT_FILE_COST = {
759
+ ".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.",
760
+ "lefthook.yml": "the pre-commit oxlint / oxfmt / check-theme jobs are not wired \u2014 add `extends: [<preset>]` to your own file.",
761
+ ".github/workflows/check.yml": "CI does not run check-theme or the sync drift gate \u2014 copy those steps into your own workflow.",
762
+ ".oxfmtrc.json": "your existing formatter config stands; basalt formats nothing on its own.",
763
+ "DESIGN.md": "the design brief Claude reads is yours, not the seeded one."
764
+ };
513
765
  function init(cwd = process.cwd(), scaffoldFlags = {}) {
766
+ const install = findBasaltInstall(cwd);
767
+ if (install.dir === null) {
768
+ console.error(
769
+ `\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
+ );
771
+ }
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
+ }
514
787
  const ctx = renderContext(cwd);
788
+ const lintScript = `oxlint . && ${ctx.vars.BASALT_BIN} check-theme`;
789
+ const seededLintScript = patchPackageJson(cwd, (pkg) => {
790
+ const scripts = pkg["scripts"] ?? {};
791
+ if (scripts["lint:basalt"] !== void 0) return false;
792
+ pkg["scripts"] = { ...scripts, "lint:basalt": lintScript };
793
+ return true;
794
+ });
515
795
  const manifest = readManifest(cwd);
516
796
  migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest);
517
797
  const peers = resolvePeerFlags(cwd, scaffoldFlags);
518
798
  const placement = resolvePlacement(cwd);
519
799
  const files = managedFiles(peers, placement);
520
- let written = 0;
521
- let skipped = 0;
800
+ const writtenFiles = [];
801
+ const keptFiles = [];
522
802
  const missingSources = [];
523
803
  for (const file of files) {
524
804
  const state = unitState(file, cwd, ctx);
@@ -529,29 +809,63 @@ function init(cwd = process.cwd(), scaffoldFlags = {}) {
529
809
  const destExists = existsSync(resolve(cwd, file.dest));
530
810
  if (!file.markers && destExists) {
531
811
  manifest.files[file.dest] = sha256(normalizeForLedger(state.desired));
532
- skipped++;
812
+ keptFiles.push(file.dest);
533
813
  continue;
534
814
  }
535
815
  const hash = writeUnit(file, cwd, state.desired);
536
816
  manifest.files[file.dest] = hash;
537
- written++;
817
+ writtenFiles.push(file.dest);
538
818
  }
819
+ const mergeLint = scaffoldFlags.mergeLint === true ? mergeOxlintExtends(cwd, ctx.vars.OXLINT_PRESET_PATH) : null;
539
820
  manifest.basaltVersion = ctx.vars.BASALT_VERSION;
540
821
  manifest.spacingScale = { ...deriveSpacing(0).scale };
541
822
  writeFileEnsuringDir(resolve(cwd, MANIFEST_PATH), `${JSON.stringify(manifest, null, 2)}
542
823
  `);
543
- console.log(`basalt-ui init: ${written} written, ${skipped} kept, manifest at ${MANIFEST_PATH}`);
824
+ console.log(
825
+ `basalt-ui init: ${writtenFiles.length} written, ${keptFiles.length} kept, manifest at ${MANIFEST_PATH}`
826
+ );
544
827
  if (missingSources.length > 0) {
545
828
  console.log(
546
829
  `basalt-ui init: ${missingSources.length} shipped asset(s) not present, skipped: ${missingSources.join(", ")}`
547
830
  );
548
831
  }
832
+ if (keptFiles.length > 0) {
833
+ console.log(`
834
+ Kept (already present \u2014 basalt did not touch them):`);
835
+ for (const dest of keptFiles) {
836
+ const cost = KEPT_FILE_COST[dest];
837
+ console.log(cost === void 0 ? ` \xB7 ${dest}` : ` \xB7 ${dest} \u2014 ${cost}`);
838
+ }
839
+ }
840
+ if (mergeLint !== null) {
841
+ const mergeMessage = {
842
+ added: `spliced "${ctx.vars.OXLINT_PRESET_PATH}" into the existing .oxlintrc.json extends`,
843
+ already: ".oxlintrc.json already extends the shipped preset \u2014 nothing to do",
844
+ absent: "no existing .oxlintrc.json to merge into (the seeded one already extends the preset)",
845
+ unreadable: ".oxlintrc.json is not valid JSON \u2014 merge the extends entry by hand",
846
+ "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.`
847
+ };
848
+ console.log(`basalt-ui init: --merge-lint \u2014 ${mergeMessage[mergeLint]}`);
849
+ }
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
+ }
860
+ if (seededLintScript) {
861
+ console.log(`basalt-ui init: added the "lint:basalt" script (${lintScript}) to package.json.`);
862
+ }
549
863
  if (!placement.isPackageRepoRoot) {
550
864
  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.`
865
+ `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
866
  );
553
867
  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.`
868
+ `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
869
  );
556
870
  }
557
871
  if (placement.relocatedQueryClient !== null) {
@@ -581,11 +895,32 @@ Theme guard hook: add to .claude/settings.json \u2192 hooks.PreToolUse to catch
581
895
  ]
582
896
  }`
583
897
  );
898
+ const presetPlugins = readOxlintPresetPlugins(ctx.pkgRoot);
899
+ if (presetPlugins.length > 0) {
900
+ console.log(
901
+ `
902
+ 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.`
903
+ );
904
+ }
905
+ console.log(
906
+ "Activate the hooks: `lefthook install` \u2014 the seeded lefthook.yml is inert until the git hooks are written, and looks configured either way."
907
+ );
584
908
  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."
909
+ `
910
+ 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
911
  );
587
912
  return 0;
588
913
  }
914
+ function readOxlintPresetPlugins(pkgRoot) {
915
+ const raw = readSource(pkgRoot, "configs/oxlint.json");
916
+ if (raw === null) return [];
917
+ try {
918
+ const plugins = JSON.parse(raw).plugins;
919
+ return isStringArray(plugins) ? plugins : [];
920
+ } catch {
921
+ return [];
922
+ }
923
+ }
589
924
  function sync(opts = {}, cwd = process.cwd()) {
590
925
  const ctx = renderContext(cwd);
591
926
  const manifest = readManifest(cwd);
@@ -725,7 +1060,7 @@ function checkCoverage() {
725
1060
  for (const exportKey of pkgExports) {
726
1061
  if (exportKey === "." || exportKey === "./styles.css" || exportKey.startsWith("./configs/"))
727
1062
  continue;
728
- if (!(exportKey in SURFACES)) {
1063
+ if (!Object.hasOwn(SURFACES, exportKey)) {
729
1064
  failures.push(`package.json exports key '${exportKey}' has no matching SURFACES entry`);
730
1065
  }
731
1066
  }
@@ -880,9 +1215,27 @@ function resolveAiMajorSkewReason(cfg) {
880
1215
  }
881
1216
  return { reason: null, presentButInvalid: true };
882
1217
  }
883
- function doctor(cwd = process.cwd()) {
1218
+ function doctor(invocationCwd = process.cwd(), flags = []) {
1219
+ if (conflictingProfileFlags(flags)) {
1220
+ console.error("basalt-ui doctor: --tokens-only and --framework are alternatives \u2014 pass one.");
1221
+ return 1;
1222
+ }
1223
+ const project = resolveProjectDir(invocationCwd);
1224
+ if (project.ambiguous !== null) {
1225
+ console.error(
1226
+ `
1227
+ basalt-ui doctor \u2014 ${invocationCwd}
1228
+
1229
+ \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.
1230
+
1231
+ 1 hard failure(s), 0 warning(s).
1232
+ `
1233
+ );
1234
+ return 1;
1235
+ }
1236
+ const cwd = project.dir;
884
1237
  const cfg = readBasaltConfig(cwd);
885
- const result = { hardFailures: 0, warnings: 0 };
1238
+ const result = { hardFailures: 0, warnings: 0, skipped: 0 };
886
1239
  const lines = [`
887
1240
  basalt-ui doctor \u2014 ${cwd}
888
1241
  `];
@@ -893,26 +1246,59 @@ basalt-ui doctor \u2014 ${cwd}
893
1246
  lines.push(` \u26A0 ${msg}`);
894
1247
  result.warnings++;
895
1248
  }
1249
+ function skip(msg) {
1250
+ lines.push(` \u2298 SKIPPED \u2014 ${msg}`);
1251
+ result.skipped++;
1252
+ }
896
1253
  function fail(msg) {
897
1254
  lines.push(` \u2716 ${msg}`);
898
1255
  result.hardFailures++;
899
1256
  }
1257
+ if (project.relocatedFrom !== null) {
1258
+ lines.push(
1259
+ ` \u2192 no basalt config at ${project.relocatedFrom} \u2014 reporting on ${relativePosix(project.relocatedFrom, cwd)}, where it lives.
1260
+ `
1261
+ );
1262
+ }
900
1263
  const manifestAbs = resolve(cwd, MANIFEST_PATH);
901
1264
  const manifestExists = existsSync(manifestAbs);
902
- if (manifestExists) {
1265
+ const profile = inferredProfile(cwd, cfg, flags);
1266
+ if (profile === "tokens-only") {
1267
+ lines.push(
1268
+ " 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"
1269
+ );
1270
+ if (cfg.profile === void 0) {
1271
+ lines.push(
1272
+ ' \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'
1273
+ );
1274
+ }
1275
+ }
1276
+ if (profile === "tokens-only") {
1277
+ pass(`${MANIFEST_PATH}: n/a \u2014 a tokens-only consumer has no scaffold to reconcile`);
1278
+ } else if (manifestExists) {
903
1279
  pass(`${MANIFEST_PATH} exists`);
904
1280
  } else {
905
1281
  fail(`${MANIFEST_PATH} missing \u2014 run \`basalt-ui init\` to scaffold the consumer repo`);
906
1282
  }
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
- }
1283
+ const install = findBasaltInstall(cwd);
1284
+ const installedVersion = install.version;
1285
+ if (install.dir === null) {
1286
+ fail(
1287
+ `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.`
1288
+ );
1289
+ } else if (install.how === "cwd") {
1290
+ pass(
1291
+ `basalt-ui resolves at ./node_modules/basalt-ui (${installedVersion ?? "unknown version"})`
1292
+ );
1293
+ } else {
1294
+ pass(
1295
+ `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.`
1296
+ );
1297
+ }
1298
+ if (profile === "framework" && manifestExists && installedVersion === null) {
1299
+ skip(
1300
+ "installed-vs-manifest version \u2014 basalt-ui did not resolve, so there is nothing to compare"
1301
+ );
916
1302
  }
917
1303
  if (manifestExists && installedVersion !== null) {
918
1304
  const manifestVersion = readManifest(cwd).basaltVersion;
@@ -930,11 +1316,17 @@ basalt-ui doctor \u2014 ${cwd}
930
1316
  }
931
1317
  const cliVersion = readFrameworkVersion(packageRoot());
932
1318
  const cliIsTheInstall = installedVersion === null || installedVersion === cliVersion;
933
- if (manifestExists && !cliIsTheInstall) {
1319
+ if (profile === "tokens-only") {
1320
+ pass("spacing scale: n/a \u2014 a tokens-only consumer renders no basalt Mantine theme");
1321
+ } else if (!manifestExists) {
1322
+ skip(
1323
+ `spacing scale \u2014 no ${MANIFEST_PATH} records the scale this app last synced with, so there is nothing to compare against`
1324
+ );
1325
+ } else if (!cliIsTheInstall) {
934
1326
  pass(
935
1327
  `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
1328
  );
937
- } else if (manifestExists) {
1329
+ } else {
938
1330
  const recorded = readManifest(cwd).spacingScale;
939
1331
  const current = deriveSpacing(0).scale;
940
1332
  if (recorded === void 0) {
@@ -951,17 +1343,54 @@ basalt-ui doctor \u2014 ${cwd}
951
1343
  }
952
1344
  }
953
1345
  }
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.`
1346
+ if (installedVersion === null) {
1347
+ skip("CLI-vs-installed version \u2014 basalt-ui did not resolve, so there is no installed version");
1348
+ } else if (installedVersion !== cliVersion) {
1349
+ warn(
1350
+ `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.`
1351
+ );
1352
+ } else {
1353
+ pass(`CLI version (${cliVersion}) matches the installed basalt-ui`);
1354
+ }
1355
+ if (profile === "tokens-only") {
1356
+ pass("guard-scan: n/a \u2014 the theme guard is a Mantine-app check");
1357
+ } else {
1358
+ const scanned = scannableFiles(cwd, cfg);
1359
+ if (scanned.length === 0) {
1360
+ fail(
1361
+ `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.`
958
1362
  );
959
1363
  } else {
960
- pass(`CLI version (${cliVersion}) matches the installed basalt-ui in node_modules`);
1364
+ pass(
1365
+ `guard-scan: check-theme covers ${scanned.length} file(s) under ${resolveRoots(cfg).join(", ")}`
1366
+ );
1367
+ }
1368
+ }
1369
+ if (profile === "tokens-only") {
1370
+ pass("oxlint-preset: n/a \u2014 the shipped preset is a Mantine/React preset");
1371
+ } else {
1372
+ const oxlintrcRaw = readIfExists(resolve(cwd, ".oxlintrc.json"));
1373
+ if (oxlintrcRaw === null) {
1374
+ fail(
1375
+ ".oxlintrc.json missing \u2014 the shipped oxlint preset (the basalt/* design rules) is not active. Run `basalt-ui init` to seed it."
1376
+ );
1377
+ } else {
1378
+ const parsed = parseJsonc(oxlintrcRaw);
1379
+ if (parsed === null) {
1380
+ skip(
1381
+ ".oxlintrc.json does not parse as JSON/JSONC \u2014 cannot tell whether it extends the preset"
1382
+ );
1383
+ } else if (extendsBasaltPreset(parsed["extends"])) {
1384
+ pass(".oxlintrc.json extends the shipped basalt-ui oxlint preset");
1385
+ } 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
+ );
1389
+ }
961
1390
  }
962
1391
  }
963
1392
  const publicDir = resolve(cwd, "public");
964
- if (existsSync(publicDir)) {
1393
+ if (profile === "framework" && existsSync(publicDir)) {
965
1394
  const iconFiles = [
966
1395
  "favicon.ico",
967
1396
  "favicon.svg",
@@ -1013,15 +1442,17 @@ basalt-ui doctor \u2014 ${cwd}
1013
1442
  pass("ai-major-parity: no workspace package declares the ai dependency \u2014 nothing to compare");
1014
1443
  }
1015
1444
  lines.push("");
1016
- if (result.hardFailures === 0 && result.warnings === 0) {
1445
+ if (result.hardFailures === 0 && result.warnings === 0 && result.skipped === 0) {
1017
1446
  lines.push("All checks passed.");
1018
1447
  } 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).`);
1448
+ const parts = [];
1449
+ if (result.hardFailures > 0) parts.push(`${result.hardFailures} hard failure(s)`);
1450
+ if (result.skipped > 0) parts.push(`${result.skipped} check(s) SKIPPED (could not run)`);
1451
+ parts.push(`${result.warnings} warning(s)`);
1452
+ lines.push(`${parts.join(", ")}.`);
1022
1453
  }
1023
1454
  lines.push("");
1024
- if (result.hardFailures > 0) {
1455
+ if (result.hardFailures > 0 || result.skipped > 0) {
1025
1456
  console.error(lines.join("\n"));
1026
1457
  return 1;
1027
1458
  }
@@ -1101,8 +1532,61 @@ function flagValue(flags, name) {
1101
1532
  const value = flags[i + 1];
1102
1533
  return value === void 0 || value.startsWith("--") ? void 0 : value;
1103
1534
  }
1535
+ function generatedHeader(version, command, flags) {
1536
+ const shown = flags.filter((f) => f !== "--check");
1537
+ const invocation = shown.length === 0 ? command : `${command} ${shown.join(" ")}`;
1538
+ return `${GENERATED_HEADER_LINE}
1539
+ /* basalt-ui ${version} \u2014 \`basalt-ui ${invocation}\` */
1540
+ `;
1541
+ }
1542
+ function normalizeColorFunctions(css) {
1543
+ return css.replace(/\b(rgba?|hsla?)\(([^()]*)\)/g, (_match, fn, args) => {
1544
+ const parts = args.split(",").map((part) => part.trim());
1545
+ return `${fn}(${parts.join(", ")})`;
1546
+ });
1547
+ }
1548
+ const SCHEME_CLASS_SENTINEL = "data-basalt-scheme-class";
1549
+ function emitGeneratedCss(content, flags, cwd, command) {
1550
+ const out = flagValue(flags, "--out");
1551
+ const check = flags.includes("--check");
1552
+ if (out === void 0) {
1553
+ if (check) {
1554
+ console.error(
1555
+ `${command}: --check needs --out <path> \u2014 there is nothing to compare stdout to.`
1556
+ );
1557
+ return 1;
1558
+ }
1559
+ process.stdout.write(content);
1560
+ return 0;
1561
+ }
1562
+ const target = isAbsolute(out) ? out : resolve(cwd, out);
1563
+ const shown = relative(cwd, target) || target;
1564
+ if (check) {
1565
+ const onDisk = readIfExists(target);
1566
+ if (onDisk === null) {
1567
+ console.error(
1568
+ `\u2716 ${command} --check: ${shown} does not exist \u2014 run without --check to write it.`
1569
+ );
1570
+ return 1;
1571
+ }
1572
+ if (onDisk === content) {
1573
+ console.log(`\u2713 ${command} --check: ${shown} is up to date.`);
1574
+ return 0;
1575
+ }
1576
+ console.error(
1577
+ `\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.`
1578
+ );
1579
+ return 1;
1580
+ }
1581
+ mkdirSync(dirname(target), { recursive: true });
1582
+ writeFileSync(target, content);
1583
+ console.log(`wrote ${content.split("\n").length} lines \u2192 ${shown}`);
1584
+ return 0;
1585
+ }
1104
1586
  function tokensCss(flags, cwd = process.cwd()) {
1105
1587
  const attribute = flagValue(flags, "--selector-attribute");
1588
+ const schemeClass = flagValue(flags, "--selector-class");
1589
+ const lightClass = flagValue(flags, "--light-class");
1106
1590
  const darkValue = flagValue(flags, "--dark-value");
1107
1591
  const lightValue = flagValue(flags, "--light-value");
1108
1592
  const defaultScheme = flagValue(flags, "--default-scheme");
@@ -1117,12 +1601,31 @@ function tokensCss(flags, cwd = process.cwd()) {
1117
1601
  console.error(`tokens:css: --only must be core or all (got '${only}')`);
1118
1602
  return 1;
1119
1603
  }
1604
+ if (schemeClass !== void 0 && attribute !== void 0) {
1605
+ console.error(
1606
+ "tokens:css: --selector-class and --selector-attribute are alternatives \u2014 pass one."
1607
+ );
1608
+ return 1;
1609
+ }
1610
+ if (schemeClass !== void 0 && /[^\w-]/.test(schemeClass)) {
1611
+ console.error(
1612
+ `tokens:css: --selector-class must be a plain CSS class name (got '${schemeClass}')`
1613
+ );
1614
+ return 1;
1615
+ }
1616
+ if (lightClass !== void 0 && /[^\w-]/.test(lightClass)) {
1617
+ console.error(`tokens:css: --light-class must be a plain CSS class name (got '${lightClass}')`);
1618
+ return 1;
1619
+ }
1620
+ const resolvedDarkValue = schemeClass !== void 0 ? schemeClass : darkValue;
1621
+ const resolvedLightValue = schemeClass !== void 0 ? lightClass ?? "light" : lightValue;
1622
+ const resolvedAttribute = schemeClass !== void 0 ? SCHEME_CLASS_SENTINEL : attribute;
1120
1623
  const scheme = {
1121
- ...attribute === void 0 ? {} : { attribute },
1122
- ...darkValue === void 0 ? {} : { darkValue },
1123
- ...lightValue === void 0 ? {} : { lightValue }
1624
+ ...resolvedAttribute === void 0 ? {} : { attribute: resolvedAttribute },
1625
+ ...resolvedDarkValue === void 0 ? {} : { darkValue: resolvedDarkValue },
1626
+ ...resolvedLightValue === void 0 ? {} : { lightValue: resolvedLightValue }
1124
1627
  };
1125
- const css = buildPaletteCss({
1628
+ let css = buildPaletteCss({
1126
1629
  ...Object.keys(scheme).length === 0 ? {} : { scheme },
1127
1630
  ...defaultScheme === void 0 ? {} : { defaultScheme },
1128
1631
  ...flags.includes("--media-fallback") ? { mediaFallback: true } : {},
@@ -1133,17 +1636,44 @@ function tokensCss(flags, cwd = process.cwd()) {
1133
1636
  // are, which is the one thing this command is documented never to allow.
1134
1637
  ...flags.includes("--no-legacy-aliases") ? { legacyAliases: false } : {}
1135
1638
  });
1136
- const out = flagValue(flags, "--out");
1137
- if (out === void 0) {
1138
- process.stdout.write(`${css}
1139
- `);
1140
- return 0;
1639
+ if (schemeClass !== void 0) {
1640
+ css = css.replace(
1641
+ new RegExp(`\\[${SCHEME_CLASS_SENTINEL}='([\\w-]+)'\\]`, "g"),
1642
+ (_match, value) => `.${value}`
1643
+ );
1141
1644
  }
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;
1645
+ const version = readFrameworkVersion(packageRoot());
1646
+ const content = `${generatedHeader(version, "tokens:css", flags)}${normalizeColorFunctions(css)}
1647
+ `;
1648
+ return emitGeneratedCss(content, flags, cwd, "tokens:css");
1649
+ }
1650
+ function readShippedFontDecls(pkgRoot) {
1651
+ const css = readSource(pkgRoot, "dist/styles.css") ?? readSource(pkgRoot, "src/styles.css");
1652
+ if (css === null) return [];
1653
+ const decls = [];
1654
+ for (const match of css.matchAll(/(--basalt-font[\w-]*)\s*:\s*([^;]+);/g)) {
1655
+ decls.push({
1656
+ name: match[1],
1657
+ value: match[2].replace(/\s+/g, " ").trim()
1658
+ });
1659
+ }
1660
+ return decls;
1661
+ }
1662
+ function fontsCss(flags, cwd = process.cwd()) {
1663
+ const pkgRoot = packageRoot();
1664
+ const decls = readShippedFontDecls(pkgRoot);
1665
+ if (decls.length === 0) {
1666
+ console.error(
1667
+ "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."
1668
+ );
1669
+ return 1;
1670
+ }
1671
+ const body = `:root {
1672
+ ${decls.map((d) => ` ${d.name}: ${d.value};`).join("\n")}
1673
+ }
1674
+ `;
1675
+ const header = generatedHeader(readFrameworkVersion(pkgRoot), "fonts:css", flags);
1676
+ return emitGeneratedCss(`${header}${body}`, flags, cwd, "fonts:css");
1147
1677
  }
1148
1678
  async function guardHook(cwd = process.cwd()) {
1149
1679
  let raw;
@@ -1190,7 +1720,10 @@ async function guardHook(cwd = process.cwd()) {
1190
1720
  sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
1191
1721
  allowComment: "theme-allow",
1192
1722
  exemptRules: cfg.exemptRules ?? DEFAULT_GUARD_CONFIG.exemptRules,
1193
- severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity
1723
+ severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
1724
+ // Same detection check-theme uses: the hook must never block an edit over advice the app
1725
+ // cannot take ("use @mantine/core's Select") in a repo with no Mantine.
1726
+ ...declaredProfile(cfg, []) === "tokens-only" ? { profile: "tokens-only" } : {}
1194
1727
  };
1195
1728
  const roots = cfg.roots ?? DEFAULT_ROOTS;
1196
1729
  const exempt = new Set(cfg.exempt ?? defaultExempt(cfg));
@@ -1222,7 +1755,7 @@ async function guardHook(cwd = process.cwd()) {
1222
1755
  }
1223
1756
  return 0;
1224
1757
  }
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.";
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.";
1226
1759
  function run(argv, cwd = process.cwd()) {
1227
1760
  const [cmd, ...flags] = argv;
1228
1761
  if (cmd === "help" || cmd === "--help" || cmd === "-h" || flags.includes("--help") || flags.includes("-h")) {
@@ -1233,22 +1766,25 @@ function run(argv, cwd = process.cwd()) {
1233
1766
  case "init":
1234
1767
  return init(cwd, {
1235
1768
  withRouter: flags.includes("--with-router"),
1236
- withQuery: flags.includes("--with-query")
1769
+ withQuery: flags.includes("--with-query"),
1770
+ mergeLint: flags.includes("--merge-lint")
1237
1771
  });
1238
1772
  case "sync":
1239
1773
  return sync({ force: flags.includes("--force"), check: flags.includes("--check") }, cwd);
1240
1774
  case "check-theme":
1241
- return checkTheme(cwd);
1775
+ return checkTheme(cwd, flags);
1242
1776
  case "check-coverage":
1243
1777
  return checkCoverage();
1244
1778
  case "info":
1245
1779
  return info(flags);
1246
1780
  case "doctor":
1247
- return doctor(cwd);
1781
+ return doctor(cwd, flags);
1248
1782
  case "guard-hook":
1249
1783
  return guardHook(cwd);
1250
1784
  case "tokens:css":
1251
1785
  return tokensCss(flags, cwd);
1786
+ case "fonts:css":
1787
+ return fontsCss(flags, cwd);
1252
1788
  default:
1253
1789
  console.error(USAGE);
1254
1790
  return 1;
@@ -1261,10 +1797,12 @@ export {
1261
1797
  checkCoverage,
1262
1798
  checkTheme,
1263
1799
  doctor,
1800
+ fontsCss,
1264
1801
  guardHook,
1265
1802
  info,
1266
1803
  init,
1267
1804
  managedFiles,
1805
+ normalizeColorFunctions,
1268
1806
  normalizeForLedger,
1269
1807
  run,
1270
1808
  sync,