basalt-ui 1.22.0 → 1.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/AGENTS.md +29 -12
  2. package/CHANGELOG.md +28 -0
  3. package/MIGRATING.md +207 -12
  4. package/README.md +74 -21
  5. package/agent/rules/basalt-app.md +33 -3
  6. package/agent/rules/basalt-charts.md +78 -7
  7. package/agent/rules/basalt-content.md +3 -3
  8. package/agent/rules/basalt-mantine.md +4 -4
  9. package/agent/rules/basalt-tokens.md +35 -6
  10. package/agent/skills/basalt-app/SKILL.md +25 -12
  11. package/agent/skills/basalt-charts/SKILL.md +27 -10
  12. package/agent/templates/CLAUDE-block.md.tpl +3 -2
  13. package/agent/templates/DESIGN.md.tpl +3 -1
  14. package/configs/oxlint-plugin.js +49 -8
  15. package/configs/oxlint-plugin.test.ts +88 -31
  16. package/dist/charts/hooks/useBandPlot.d.ts +138 -0
  17. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
  18. package/dist/charts/hooks/useBandPlot.js +127 -0
  19. package/dist/charts/index.d.ts +4 -0
  20. package/dist/charts/index.d.ts.map +1 -1
  21. package/dist/charts/index.js +14 -0
  22. package/dist/charts/kinds/BandStrip.d.ts +127 -0
  23. package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
  24. package/dist/charts/kinds/BandStrip.js +271 -0
  25. package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
  26. package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
  27. package/dist/charts/kinds/Bars.d.ts +5 -1
  28. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  29. package/dist/charts/kinds/Bars.js +2 -0
  30. package/dist/charts/kinds/MirroredBars.d.ts +95 -0
  31. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
  32. package/dist/charts/kinds/MirroredBars.js +328 -0
  33. package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
  34. package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
  35. package/dist/charts/kinds/MultiLine.d.ts +5 -1
  36. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  37. package/dist/charts/kinds/MultiLine.js +2 -0
  38. package/dist/charts/kinds/StackedArea.d.ts +5 -1
  39. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  40. package/dist/charts/kinds/StackedArea.js +2 -0
  41. package/dist/charts/kinds/ZonedLine.d.ts +5 -1
  42. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  43. package/dist/charts/kinds/ZonedLine.js +2 -0
  44. package/dist/charts/primitives/CartesianChart.d.ts +18 -2
  45. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  46. package/dist/charts/primitives/CartesianChart.js +5 -2
  47. package/dist/charts/primitives/HatchPattern.d.ts +31 -0
  48. package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
  49. package/dist/charts/primitives/HatchPattern.js +31 -0
  50. package/dist/cli/index.d.ts +2 -0
  51. package/dist/cli/index.d.ts.map +1 -1
  52. package/dist/cli/index.js +228 -31
  53. package/dist/cli/shipped-versions.test.d.ts +2 -0
  54. package/dist/cli/shipped-versions.test.d.ts.map +1 -0
  55. package/dist/guard/index.d.ts.map +1 -1
  56. package/dist/guard/index.js +60 -13
  57. package/dist/vite.d.ts +42 -3
  58. package/dist/vite.d.ts.map +1 -1
  59. package/dist/vite.js +67 -30
  60. package/llms.txt +2 -2
  61. package/package.json +1 -1
  62. package/src/charts/hooks/useBandPlot.ts +302 -0
  63. package/src/charts/index.ts +14 -0
  64. package/src/charts/kinds/BandStrip.test.tsx +282 -0
  65. package/src/charts/kinds/BandStrip.tsx +457 -0
  66. package/src/charts/kinds/Bars.tsx +7 -1
  67. package/src/charts/kinds/DualPanel.test.tsx +10 -4
  68. package/src/charts/kinds/MirroredBars.test.tsx +232 -0
  69. package/src/charts/kinds/MirroredBars.tsx +491 -0
  70. package/src/charts/kinds/MultiLine.tsx +7 -1
  71. package/src/charts/kinds/StackedArea.tsx +7 -1
  72. package/src/charts/kinds/ZonedLine.tsx +7 -1
  73. package/src/charts/primitives/CartesianChart.test.tsx +86 -4
  74. package/src/charts/primitives/CartesianChart.tsx +28 -3
  75. package/src/charts/primitives/HatchPattern.tsx +55 -0
  76. package/src/cli/help.test.ts +74 -1
  77. package/src/cli/index.ts +436 -52
  78. package/src/cli/shipped-versions.test.ts +83 -0
  79. package/src/cli/tokens-css.test.ts +62 -0
  80. package/src/cli/toolchain-wiring.test.ts +171 -4
  81. package/src/cli/waiver-audit.test.ts +7 -3
  82. package/src/guard/check-source.test.ts +211 -42
  83. package/src/guard/index.ts +149 -23
  84. package/src/vite.test.ts +79 -0
  85. package/src/vite.ts +141 -39
package/src/cli/index.ts CHANGED
@@ -511,6 +511,34 @@ type ProjectResolution = {
511
511
  ambiguous: string[] | null
512
512
  }
513
513
 
514
+ /**
515
+ * Basalt projects in `cwd`'s immediate subtree — the fallback for a repo that declares no
516
+ * `workspaces` field at all.
517
+ *
518
+ * {@link collectWorkspacePackages} is the primary source and it is exact, but it answers `[]` for
519
+ * every repo whose root `package.json` carries no `workspaces` key. linewatch is that shape: the
520
+ * entire basalt consumer lives in `web/`, with no workspace declaration anywhere. Measured at its
521
+ * repo root, all three project-scoped commands got that wrong in a different way — `check-theme`
522
+ * printed `✓ no off-palette colors` having scanned ZERO files, `doctor` inferred `tokens-only` for
523
+ * a full Mantine consumer, and `sync` refused while naming `basalt-ui init` as the remedy, which is
524
+ * the one command that WOULD have written a competing install at the root. A gate that reports
525
+ * green having looked at nothing is the failure this resolver exists to prevent, so the search
526
+ * falls back to the layout when there is no declaration to read.
527
+ *
528
+ * Bounded to two levels (`web/`, `apps/admin/`), dot-directories and `node_modules` skipped, and it
529
+ * never descends INTO a project it found — a consumer's own nested package is not a rival candidate.
530
+ */
531
+ function descendantProjects(cwd: string, depth = 2): string[] {
532
+ if (depth === 0) return []
533
+ const out: string[] = []
534
+ for (const name of subdirNames(cwd, { skipDotDirs: true })) {
535
+ const dir = resolve(cwd, name)
536
+ if (hasBasaltProject(dir)) out.push(dir)
537
+ else out.push(...descendantProjects(dir, depth - 1))
538
+ }
539
+ return out
540
+ }
541
+
514
542
  function resolveProjectDir(cwd: string): ProjectResolution {
515
543
  const override = process.env['BASALT_CWD']
516
544
  if (override !== undefined && override.length > 0) {
@@ -519,9 +547,13 @@ function resolveProjectDir(cwd: string): ProjectResolution {
519
547
  }
520
548
  if (hasBasaltProject(cwd)) return { dir: cwd, relocatedFrom: null, ambiguous: null }
521
549
 
522
- const candidates = collectWorkspacePackages(cwd)
550
+ const declared = collectWorkspacePackages(cwd)
523
551
  .packages.filter((pkg) => pkg.dir !== cwd && hasBasaltProject(pkg.dir))
524
552
  .map((pkg) => pkg.dir)
553
+ // The workspace declaration wins whenever there is one — it is exact, and a repo that declares
554
+ // its packages has already said where they are. The layout scan only answers for the repos that
555
+ // declare nothing; see descendantProjects.
556
+ const candidates = declared.length > 0 ? declared : descendantProjects(cwd)
525
557
  if (candidates.length === 1) {
526
558
  return { dir: candidates[0] as string, relocatedFrom: cwd, ambiguous: null }
527
559
  }
@@ -692,7 +724,7 @@ export function checkTheme(
692
724
  if (project.ambiguous !== null) {
693
725
  console.error(
694
726
  `✖ basalt-ui check-theme: no basalt config at ${invocationCwd}, and ${project.ambiguous.length} ` +
695
- `workspace packages carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
727
+ `packages below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
696
728
  'run it from one of them, or set BASALT_CWD to pick.',
697
729
  )
698
730
  return 1
@@ -1176,13 +1208,21 @@ function auditAllows(
1176
1208
  `\n${live} live, ${dead} dead, ${outOfReach} unjudgeable, ${unaccountable} ` +
1177
1209
  'unaccountable (reported as theme-allow-unscoped by a normal run).',
1178
1210
  )
1179
- // The audit reads exactly what `check-theme` reads, and that is `basalt.roots`. A waiver in a
1180
- // file outside them is invisible here — not because it is fine, but because nothing scanned it.
1181
- // Saying the scope out loud is the difference between "0 dead" and "0 dead, over these files".
1211
+ // The audit reads exactly what `check-theme` reads, and that is `scannableFiles` — which is
1212
+ // WIDER than `basalt.roots`: it also takes each root's sibling `index.html` and `public/` tree
1213
+ // (see appShellFiles) and anything named in `basalt.include`. Two consumers reported a live
1214
+ // `public/site.webmanifest` waiver under a scope line saying files outside `roots` are not
1215
+ // audited; the line was wrong, not the scan. Saying the scope out loud is the difference between
1216
+ // "0 dead" and "0 dead, over these files" — so it has to name every class actually reached.
1217
+ const includes = cfg.include ?? []
1182
1218
  lines.push(
1183
- `Scope: the ${scanned.length} file(s) check-theme scans under basalt.roots ` +
1184
- `(${resolveRoots(cfg).join(', ')}). A waiver outside them is not audited — widen roots to ` +
1185
- 'cover it, or accept that nothing polices that file.',
1219
+ `Scope: the ${scanned.length} file(s) check-theme scans — everything under basalt.roots ` +
1220
+ `(${resolveRoots(cfg).join(', ')}), plus each root's sibling index.html and public/ tree, ` +
1221
+ (includes.length === 0
1222
+ ? 'plus anything named in basalt.include (none). '
1223
+ : `plus basalt.include (${includes.join(', ')}). `) +
1224
+ 'A waiver in a file none of those reach is not audited — widen roots, or name it in ' +
1225
+ 'basalt.include, or accept that nothing polices that file.',
1186
1226
  )
1187
1227
  if (dead > 0) {
1188
1228
  lines.push(
@@ -1312,10 +1352,25 @@ function readFrameworkVersion(pkgRoot: string): string {
1312
1352
  }
1313
1353
  }
1314
1354
 
1355
+ /**
1356
+ * How a seeded script, a seeded CI step, or a piece of doctor advice should invoke the CLI.
1357
+ *
1358
+ * The LOCAL bin wherever basalt actually resolved; `bunx basalt-ui` only as the pre-install
1359
+ * fallback. `bunx` does not re-resolve a package it has cached, so a seeded `bunx basalt-ui` lets a
1360
+ * consumer pin one version and be gated by another — which is exactly what happened across three
1361
+ * consumer repos, in CI, in package.json scripts and in a `.claude` PreToolUse hook, all of them
1362
+ * placed here. Every emitted invocation goes through this function so there is one place for it to
1363
+ * be right.
1364
+ */
1365
+ function basaltBinCommand(install: BasaltInstall, cwd: string): string {
1366
+ if (install.dir === null) return 'bunx basalt-ui'
1367
+ const binDir = resolve(install.dir, '..', '.bin')
1368
+ return `${relativePosix(cwd, binDir).replace(/^\.\//, '')}/basalt-ui`
1369
+ }
1370
+
1315
1371
  /** Build the template-variable map from the framework root + consumer cwd/config. */
1316
1372
  function buildTemplateVars(pkgRoot: string, cwd: string, cfg: BasaltConfig): TemplateVars {
1317
1373
  const install = findBasaltInstall(cwd)
1318
- const binDir = install.dir === null ? null : resolve(install.dir, '..', '.bin')
1319
1374
  return {
1320
1375
  APP_NAME: readPackageName(cwd),
1321
1376
  BASALT_VERSION: readFrameworkVersion(pkgRoot),
@@ -1324,10 +1379,7 @@ function buildTemplateVars(pkgRoot: string, cwd: string, cfg: BasaltConfig): Tem
1324
1379
  ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(' '),
1325
1380
  OXLINT_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/oxlint.json'),
1326
1381
  LEFTHOOK_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/lefthook.yml'),
1327
- BASALT_BIN:
1328
- binDir === null
1329
- ? 'bunx basalt-ui'
1330
- : `${relativePosix(cwd, binDir).replace(/^\.\//, '')}/basalt-ui`,
1382
+ BASALT_BIN: basaltBinCommand(install, cwd),
1331
1383
  }
1332
1384
  }
1333
1385
 
@@ -1743,6 +1795,34 @@ function migrateLegacyOxfmt(cwd: string, pkgRoot: string, manifest: Manifest): v
1743
1795
  if (shipped !== null && onDisk === shipped) unlinkSync(legacyAbs)
1744
1796
  }
1745
1797
 
1798
+ /** The frozen `> Managed by basalt-ui (1.9.0).` opener every pre-fix DESIGN.md seed carries. */
1799
+ const DESIGN_VERSION_LINE = /^(> Managed by basalt-ui) \(\d+\.\d+\.\d+\)\./m
1800
+
1801
+ /**
1802
+ * Strip the frozen version out of an already-seeded `DESIGN.md` opener.
1803
+ *
1804
+ * `DESIGN.md` is a `seed`: written once, then consumer-owned and never reconciled. It opened with
1805
+ * `> Managed by basalt-ui ({{BASALT_VERSION}})`, which is accurate for exactly as long as it takes
1806
+ * to run one upgrade — measured across the seven consumers the same line read 1.0.0, 1.9.0, 1.21.0
1807
+ * and 1.22.0 under an identical 1.22.0 install, and three rounds of reports treated that as four
1808
+ * separate doc bugs. It was one: a version number in a file nothing ever rewrites.
1809
+ *
1810
+ * Dropping it from the template fixes new scaffolds only — every existing consumer would carry its
1811
+ * stale number forever, since sync does not touch a seed. So sync deletes the parenthetical in
1812
+ * place, and only that: not a re-seed, not a three-way merge. Everything else in the file is left
1813
+ * exactly as the consumer wrote it, and a DESIGN.md whose opener was already rewritten by hand
1814
+ * does not match at all.
1815
+ */
1816
+ function migrateDesignVersionLine(cwd: string): void {
1817
+ const dest = resolve(cwd, 'DESIGN.md')
1818
+ const onDisk = readIfExists(dest)
1819
+ if (onDisk === null || !DESIGN_VERSION_LINE.test(onDisk)) return
1820
+ writeFileEnsuringDir(
1821
+ dest,
1822
+ onDisk.replace(DESIGN_VERSION_LINE, '$1 — run `basalt-ui doctor` for the version.'),
1823
+ )
1824
+ }
1825
+
1746
1826
  function writeFileEnsuringDir(abs: string, content: string): void {
1747
1827
  mkdirSync(dirname(abs), { recursive: true })
1748
1828
  writeFileSync(abs, content)
@@ -2474,7 +2554,7 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
2474
2554
  ` "PreToolUse": [\n` +
2475
2555
  ` {\n` +
2476
2556
  ` "matcher": "Write|Edit|MultiEdit",\n` +
2477
- ` "hooks": [{ "type": "command", "command": "bunx basalt-ui guard-hook" }]\n` +
2557
+ ` "hooks": [{ "type": "command", "command": "${ctx.vars.BASALT_BIN} guard-hook" }]\n` +
2478
2558
  ` }\n` +
2479
2559
  ` ]\n` +
2480
2560
  ` }`,
@@ -2519,7 +2599,12 @@ function readOxlintPresetPlugins(pkgRoot: string): string[] {
2519
2599
  }
2520
2600
  }
2521
2601
 
2522
- type SyncOptions = { force?: boolean; check?: boolean }
2602
+ type SyncOptions = {
2603
+ force?: boolean
2604
+ check?: boolean
2605
+ /** The raw flag list, read for `--tokens-only` / `--framework` exactly as check-theme reads it. */
2606
+ flags?: readonly string[]
2607
+ }
2523
2608
 
2524
2609
  /**
2525
2610
  * The nearest ANCESTOR of `dir` (exclusive) carrying a basalt manifest, or null.
@@ -2530,6 +2615,21 @@ type SyncOptions = { force?: boolean; check?: boolean }
2530
2615
  * install lives at the repo root above it. Nothing walks up, so `sync` read "no manifest here" as
2531
2616
  * "nothing scaffolded yet" and wrote a complete second consumer beside the real one.
2532
2617
  */
2618
+ /**
2619
+ * The sentence `sync` prints when the cwd carries no install but an ancestor does.
2620
+ *
2621
+ * Shared with `doctor` deliberately. The two commands already share `resolveProjectDir`, but not
2622
+ * the ADVICE: `sync` named the parent install (1.22.0's "stop scaffolding a second consumer" fix)
2623
+ * while `doctor`, in the same directory, kept prescribing `basalt-ui init` — so following doctor
2624
+ * literally performed the exact mistake that fix exists to prevent. One function, one sentence.
2625
+ */
2626
+ function parentInstallAdvice(cwd: string, parent: string, verb: string): string {
2627
+ return (
2628
+ `This repo's install is at ${relativePosix(cwd, parent)} (${parent}) — run ${verb} there, ` +
2629
+ 'or set BASALT_CWD to it.'
2630
+ )
2631
+ }
2632
+
2533
2633
  function findManifestAbove(dir: string): string | null {
2534
2634
  let current = resolve(dir, '..')
2535
2635
  for (;;) {
@@ -2554,11 +2654,16 @@ function findManifestAbove(dir: string): string | null {
2554
2654
  * `sync` does not get to make silently.
2555
2655
  */
2556
2656
  export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd()): number {
2657
+ const syncFlags = opts.flags ?? []
2658
+ if (conflictingProfileFlags(syncFlags)) {
2659
+ console.error('basalt-ui sync: --tokens-only and --framework are alternatives — pass one.')
2660
+ return 1
2661
+ }
2557
2662
  const project = resolveProjectDir(invocationCwd)
2558
2663
  if (project.ambiguous !== null) {
2559
2664
  console.error(
2560
2665
  `✖ basalt-ui sync: no basalt config at ${invocationCwd}, and ${project.ambiguous.length} ` +
2561
- `workspace packages carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
2666
+ `packages below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
2562
2667
  'run it from one of them, or set BASALT_CWD to pick.',
2563
2668
  )
2564
2669
  return 1
@@ -2571,6 +2676,24 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
2571
2676
  )
2572
2677
  }
2573
2678
 
2679
+ // Profile before the refusal, because in a tokens-only consumer the refusal's own advice is
2680
+ // wrong: it names `basalt-ui init`, and `doctor` in the same directory at the same version says
2681
+ // in so many words that init is NOT the fix there — it places a Mantine doctrine the consumer
2682
+ // has no use for. Mirrors doctor's manifest row verbatim, and exits 0 so `sync --check` is
2683
+ // wirable into a tokens-only repo's CI (the one drift gate every other consumer runs).
2684
+ //
2685
+ // DECLARED, never inferred — the same asymmetry check-theme applies, for the same reason: the
2686
+ // answer SILENCES the whole reconciliation, and inferring it from a missing @mantine/core would
2687
+ // turn a framework consumer that keeps Mantine in a sibling package into a silent no-op.
2688
+ if (declaredProfile(readBasaltConfig(cwd), syncFlags) === 'tokens-only') {
2689
+ console.log(
2690
+ `basalt-ui sync: ${MANIFEST_PATH}: n/a — a tokens-only consumer has no scaffold to ` +
2691
+ 'reconcile. The token layer is refreshed by re-running `basalt-ui tokens:css`, and ' +
2692
+ '`tokens:css --check` is its drift gate. Pass --framework to force the full profile.',
2693
+ )
2694
+ return 0
2695
+ }
2696
+
2574
2697
  // The refusal runs BEFORE reconcileRoots, which WRITES `basalt.roots` into package.json — that
2575
2698
  // key is half the damage: a second `basalt` block means check-theme scans a different tree
2576
2699
  // depending on which directory it was invoked from.
@@ -2585,8 +2708,7 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
2585
2708
  (elsewhere === null
2586
2709
  ? ' No manifest exists at any ancestor either — run `basalt-ui init` here if this ' +
2587
2710
  'package really is meant to be a consumer.'
2588
- : ` This repo's install is at ${relativePosix(cwd, elsewhere)} (${elsewhere}) — run ` +
2589
- 'sync there, or set BASALT_CWD to it.'),
2711
+ : ` ${parentInstallAdvice(cwd, elsewhere, 'sync')}`),
2590
2712
  )
2591
2713
  return 1
2592
2714
  }
@@ -2595,7 +2717,10 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
2595
2717
  const rootsState = reconcileRoots(cwd, { write: opts.check !== true })
2596
2718
  const ctx = renderContext(cwd)
2597
2719
  const manifest = readManifest(cwd)
2598
- if (!opts.check) migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
2720
+ if (!opts.check) {
2721
+ migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
2722
+ migrateDesignVersionLine(cwd)
2723
+ }
2599
2724
  const peers = resolvePeerFlags(cwd, {})
2600
2725
  const placement = resolvePlacement(cwd)
2601
2726
  const files = managedFiles(peers, placement)
@@ -3228,8 +3353,8 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
3228
3353
  if (project.ambiguous !== null) {
3229
3354
  console.error(
3230
3355
  `\nbasalt-ui doctor — ${invocationCwd}\n\n` +
3231
- ` ✖ no basalt config at ${invocationCwd}, and ${project.ambiguous.length} workspace ` +
3232
- `packages carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
3356
+ ` ✖ no basalt config at ${invocationCwd}, and ${project.ambiguous.length} packages ` +
3357
+ `below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
3233
3358
  'run doctor from one of them, or set BASALT_CWD to pick.\n\n' +
3234
3359
  '1 hard failure(s), 0 warning(s).\n',
3235
3360
  )
@@ -3266,6 +3391,10 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
3266
3391
  // ── Profile ────────────────────────────────────────────────────────────────
3267
3392
  const manifestAbs = resolve(cwd, MANIFEST_PATH)
3268
3393
  const manifestExists = existsSync(manifestAbs)
3394
+ // Non-null ⇒ this directory is a package UNDER a configured repo, not an unscaffolded consumer.
3395
+ // Every remedy below that would otherwise say "run `basalt-ui init`" points at the parent
3396
+ // instead — see {@link parentInstallAdvice}.
3397
+ const parentInstall = manifestExists ? null : findManifestAbove(cwd)
3269
3398
  const profile = inferredProfile(cwd, cfg, flags)
3270
3399
  if (profile === 'tokens-only') {
3271
3400
  lines.push(
@@ -3289,6 +3418,12 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
3289
3418
  pass(`${MANIFEST_PATH}: n/a — a tokens-only consumer has no scaffold to reconcile`)
3290
3419
  } else if (manifestExists) {
3291
3420
  pass(`${MANIFEST_PATH} exists`)
3421
+ } else if (parentInstall !== null) {
3422
+ fail(
3423
+ `${MANIFEST_PATH} missing here, but this is not an unscaffolded consumer. ` +
3424
+ `${parentInstallAdvice(cwd, parentInstall, 'doctor')} \`basalt-ui init\` is NOT the fix: ` +
3425
+ 'it would scaffold a SECOND consumer beside the real one.',
3426
+ )
3292
3427
  } else {
3293
3428
  fail(`${MANIFEST_PATH} missing — run \`basalt-ui init\` to scaffold the consumer repo`)
3294
3429
  }
@@ -3450,7 +3585,11 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
3450
3585
  if (oxlintrcRaw === null) {
3451
3586
  fail(
3452
3587
  '.oxlintrc.json missing — the shipped oxlint preset (the basalt/* design rules) is not ' +
3453
- 'active. Run `basalt-ui init` to seed it.',
3588
+ 'active. ' +
3589
+ (parentInstall === null
3590
+ ? 'Run `basalt-ui init` to seed it.'
3591
+ : `${parentInstallAdvice(cwd, parentInstall, 'doctor')} The preset is seeded once, at ` +
3592
+ 'the install — not per package.'),
3454
3593
  )
3455
3594
  } else {
3456
3595
  // JSONC: oxlint accepts comments and real consumer configs carry them.
@@ -3543,40 +3682,61 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
3543
3682
  `${lefthookGate.file}: \`lefthook dump\` resolves the merged config and NO pre-commit ` +
3544
3683
  'command runs check-theme — the theme guard is not gating commits. Either add ' +
3545
3684
  `"extends: [${lefthookCorrect}]", or add your own command running ` +
3546
- '`bunx basalt-ui check-theme` (with `root:` if it must run in a subdirectory).',
3685
+ `\`${basaltBinCommand(install, cwd)} check-theme\` (with \`root:\` if it must run in a ` +
3686
+ 'subdirectory).',
3547
3687
  )
3548
3688
  } else {
3549
3689
  warn(
3550
3690
  `${lefthookGate.file} names no basalt gate and \`lefthook dump\` could not be run here, so ` +
3551
3691
  'this check cannot tell a missing gate from one wired through an `include:` or a remote ' +
3552
3692
  'config it never read — treat it as advisory. If the guard really is unwired, add ' +
3553
- `"extends: [${lefthookCorrect}]" or your own command running \`bunx basalt-ui check-theme\`.`,
3693
+ `"extends: [${lefthookCorrect}]" or your own command running ` +
3694
+ `\`${basaltBinCommand(install, cwd)} check-theme\`.`,
3554
3695
  )
3555
3696
  }
3556
3697
 
3557
- // ── Warn check 8: basaltAppPlugin's default icon files exist in public/ ────
3558
- // basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references these filenames by
3559
- // default (the realfavicongenerator convention). Only runs when a public/ dir exists at all —
3560
- // apps that don't use the plugin, or don't use Vite's public-dir convention, get no false
3561
- // warning. Best-effort: a custom `icons.dir` isn't visible here, so this checks the root only.
3698
+ // ── Warn check 8: the icon files basaltAppPlugin actually references exist ────
3699
+ // basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references six filenames by default
3700
+ // (the realfavicongenerator convention), but since 1.23.0 an app can NAME its own — so the option
3701
+ // is read (see {@link readAppIconsOption}) rather than assumed. Only runs when a public/ dir
3702
+ // exists at all: apps that don't use the plugin, or don't use Vite's public-dir convention, get
3703
+ // no false warning.
3562
3704
  const publicDir = resolve(cwd, 'public')
3563
3705
  if (profile === 'framework' && existsSync(publicDir)) {
3564
- const iconFiles = [
3565
- 'favicon.ico',
3566
- 'favicon.svg',
3567
- 'favicon-96x96.png',
3568
- 'apple-touch-icon.png',
3569
- 'web-app-manifest-192x192.png',
3570
- 'web-app-manifest-512x512.png',
3571
- ]
3572
- const missingIcons = iconFiles.filter((f) => !existsSync(resolve(publicDir, f)))
3573
- if (missingIcons.length > 0) {
3574
- warn(
3575
- `public/ is missing basaltAppPlugin icon file(s): ${missingIcons.join(', ')} — generate ` +
3576
- 'them (e.g. via realfavicongenerator.net) and place them at the public/ root.',
3706
+ const icons = readAppIconsOption(cwd)
3707
+ if (icons.kind === 'none') {
3708
+ pass('basaltAppPlugin is configured with no icons — nothing to check')
3709
+ } else if (icons.kind === 'explicit') {
3710
+ // The app NAMED its icons, so those are the only files it owes. Checking the six defaults
3711
+ // here is what told 1.23.0's flagship adopter to generate five files it deliberately lacks.
3712
+ const missing = icons.sources.filter(
3713
+ (src) => !existsSync(resolve(publicDir, src.replace(/^\//, ''))),
3577
3714
  )
3715
+ if (missing.length > 0) {
3716
+ warn(
3717
+ `basaltAppPlugin declares icon(s) that are not in public/: ${missing.join(', ')} — the ` +
3718
+ 'manifest and the head links will point at a 404. Place them, or drop them from the ' +
3719
+ '`icons` array.',
3720
+ )
3721
+ } else {
3722
+ pass(
3723
+ `public/ has all ${icons.sources.length} icon file(s) basaltAppPlugin's \`icons\` option names`,
3724
+ )
3725
+ }
3578
3726
  } else {
3579
- pass("public/ has all of basaltAppPlugin's default icon files")
3727
+ const dir = icons.kind === 'default' ? icons.dir : null
3728
+ const iconRoot = dir === null ? publicDir : resolve(publicDir, dir.replace(/^\//, ''))
3729
+ const shown = dir === null ? 'public/' : `public/${dir.replace(/^\//, '')}`
3730
+ const missingIcons = DEFAULT_APP_ICON_FILES.filter((f) => !existsSync(resolve(iconRoot, f)))
3731
+ if (missingIcons.length > 0) {
3732
+ warn(
3733
+ `${shown} is missing basaltAppPlugin icon file(s): ${missingIcons.join(', ')} — generate ` +
3734
+ `them (e.g. via realfavicongenerator.net) and place them at the ${shown} root, or name ` +
3735
+ "the icons this app really has via the plugin's `icons` array.",
3736
+ )
3737
+ } else {
3738
+ pass(`${shown} has all of basaltAppPlugin's default icon files`)
3739
+ }
3580
3740
  }
3581
3741
  }
3582
3742
 
@@ -3783,6 +3943,100 @@ export function info(flags: string[]): number {
3783
3943
  return 0
3784
3944
  }
3785
3945
 
3946
+ /** The six filenames `basaltAppPlugin` references when `icons` is left at its default. */
3947
+ const DEFAULT_APP_ICON_FILES = [
3948
+ 'favicon.ico',
3949
+ 'favicon.svg',
3950
+ 'favicon-96x96.png',
3951
+ 'apple-touch-icon.png',
3952
+ 'web-app-manifest-192x192.png',
3953
+ 'web-app-manifest-512x512.png',
3954
+ ] as const
3955
+
3956
+ const VITE_CONFIG_FILES = [
3957
+ 'vite.config.ts',
3958
+ 'vite.config.mts',
3959
+ 'vite.config.cts',
3960
+ 'vite.config.js',
3961
+ 'vite.config.mjs',
3962
+ 'vite.config.cjs',
3963
+ ] as const
3964
+
3965
+ /**
3966
+ * What `basaltAppPlugin`'s `icons` option says this app actually has.
3967
+ *
3968
+ * `unknown` = no vite config, no `basaltAppPlugin(` call, or an `icons` value this bounded read
3969
+ * cannot classify — all three fall back to the six-default check, so the gate can only ever get
3970
+ * NARROWER than it was, never blinder.
3971
+ */
3972
+ type AppIconsOption =
3973
+ | { kind: 'default'; dir: string | null }
3974
+ | { kind: 'none' }
3975
+ | { kind: 'explicit'; sources: readonly string[] }
3976
+ | { kind: 'unknown' }
3977
+
3978
+ /** The balanced `(`…`)` / `{`…`}` / `[`…`]` span starting at `open`, or null if it never closes. */
3979
+ function balancedSpan(text: string, open: number): string | null {
3980
+ const pairs: Record<string, string> = { '(': ')', '{': '}', '[': ']' }
3981
+ const closer = pairs[text[open] as string]
3982
+ if (closer === undefined) return null
3983
+ let depth = 0
3984
+ for (let i = open; i < text.length; i++) {
3985
+ const ch = text[i] as string
3986
+ if (ch === text[open]) depth++
3987
+ else if (ch === closer) {
3988
+ depth--
3989
+ if (depth === 0) return text.slice(open, i + 1)
3990
+ }
3991
+ }
3992
+ return null
3993
+ }
3994
+
3995
+ /**
3996
+ * Read the `icons` option out of the consumer's vite config.
3997
+ *
3998
+ * 1.23.0 let `icons` name an app's REAL icon files; doctor's icon check kept demanding the six
3999
+ * default filenames and never read the option, so adopting the release's headline feature produced
4000
+ * a brand-new warning — on the very repo the feature was written for. A warning that fires BECAUSE
4001
+ * you adopted the new API teaches people to ignore doctor.
4002
+ *
4003
+ * doctor does not run the build, so the plugin's resolved answer is not available to it; this is a
4004
+ * bounded textual read of the config file, the same shape the guard uses on source. It is
4005
+ * deliberately conservative: anything it cannot classify returns `unknown` and the old check runs.
4006
+ */
4007
+ function readAppIconsOption(cwd: string): AppIconsOption {
4008
+ const configPath = VITE_CONFIG_FILES.map((f) => resolve(cwd, f)).find((f) => existsSync(f))
4009
+ if (configPath === undefined) return { kind: 'unknown' }
4010
+ const text = readIfExists(configPath)
4011
+ if (text === null) return { kind: 'unknown' }
4012
+ const call = text.indexOf('basaltAppPlugin(')
4013
+ if (call === -1) return { kind: 'unknown' }
4014
+ const args = balancedSpan(text, call + 'basaltAppPlugin'.length)
4015
+ if (args === null) return { kind: 'unknown' }
4016
+ const key = args.search(/\bicons\s*:/)
4017
+ if (key === -1) return { kind: 'default', dir: null }
4018
+ const valueStart = args.indexOf(':', key) + 1
4019
+ const rest = args.slice(valueStart).trimStart()
4020
+ if (rest.startsWith('false')) return { kind: 'none' }
4021
+ const offset = args.length - rest.length
4022
+ if (rest.startsWith('[')) {
4023
+ const list = balancedSpan(args, offset)
4024
+ if (list === null) return { kind: 'unknown' }
4025
+ const sources = [...list.matchAll(/\bsrc\s*:\s*['"`]([^'"`]+)['"`]/g)].map(
4026
+ (m) => m[1] as string,
4027
+ )
4028
+ // An empty array means "this app has no icons" to the plugin, same as `false`.
4029
+ return sources.length === 0 ? { kind: 'none' } : { kind: 'explicit', sources }
4030
+ }
4031
+ if (rest.startsWith('{')) {
4032
+ const obj = balancedSpan(args, offset)
4033
+ if (obj === null) return { kind: 'unknown' }
4034
+ const dir = /\bdir\s*:\s*['"`]([^'"`]+)['"`]/.exec(obj)
4035
+ return { kind: 'default', dir: dir === null ? null : (dir[1] as string) }
4036
+ }
4037
+ return { kind: 'unknown' }
4038
+ }
4039
+
3786
4040
  /** Read the value that follows a `--flag` in an argv slice; `undefined` when absent or terminal. */
3787
4041
  function flagValue(flags: string[], name: string): string | undefined {
3788
4042
  const i = flags.indexOf(name)
@@ -3805,6 +4059,32 @@ function generatedHeader(version: string, command: string, flags: string[]): str
3805
4059
  return `${GENERATED_HEADER_LINE}\n/* basalt-ui ${version} — \`basalt-ui ${invocation}\` */\n`
3806
4060
  }
3807
4061
 
4062
+ /**
4063
+ * The generated file with its provenance line (line 2) blanked — what `--check` actually compares.
4064
+ *
4065
+ * Line 2 carries the EMITTING VERSION, and `--check` gated the whole file byte-for-byte, so every
4066
+ * basalt-ui release forced a mandatory no-op commit in every consumer that commits a generated
4067
+ * sheet: regenerate, one line changes, nothing else, ship it. That is the rot pattern removed from
4068
+ * DESIGN.md this cycle, made worse by a gate that is byte-equality.
4069
+ *
4070
+ * The version STAYS in the file rather than being dropped the way DESIGN.md's was, because the
4071
+ * consumer this command exists for — a framework-free site running `bunx basalt-ui tokens:css` with
4072
+ * no basalt in its dependency tree at all — has no other record of which version emitted it, and no
4073
+ * `doctor` to ask. What changes is that the line no longer gates: drift is measured over the token
4074
+ * VALUES, which is the only part of the file a release can meaningfully change.
4075
+ *
4076
+ * Blanked, not dropped, so the two sides stay line-aligned and the reported line counts still match
4077
+ * the files on disk. The `@generated` exemption is untouched by all of this — the guard requires
4078
+ * line 1 verbatim and line 2 parsing as the provenance line, and both are still emitted exactly as
4079
+ * {@link generatedHeader} builds them.
4080
+ */
4081
+ function withoutProvenanceLine(css: string): string {
4082
+ const lines = css.split('\n')
4083
+ if (lines.length < 2) return css
4084
+ lines[1] = ''
4085
+ return lines.join('\n')
4086
+ }
4087
+
3808
4088
  /**
3809
4089
  * Space- and number-normalize the argument list of a legacy color function so the emitted file
3810
4090
  * survives a normal repo's formatter.
@@ -3867,14 +4147,22 @@ function emitGeneratedCss(content: string, flags: string[], cwd: string, command
3867
4147
  )
3868
4148
  return 1
3869
4149
  }
3870
- if (onDisk === content) {
3871
- console.log(`✓ ${command} --check: ${shown} is up to date.`)
4150
+ if (withoutProvenanceLine(onDisk) === withoutProvenanceLine(content)) {
4151
+ const staleProvenance = onDisk.split('\n')[1] !== content.split('\n')[1]
4152
+ console.log(
4153
+ `✓ ${command} --check: ${shown} is up to date.` +
4154
+ (staleProvenance
4155
+ ? ' (Its provenance line still names an older basalt-ui; that line is deliberately not ' +
4156
+ 'gated — re-run without --check whenever you want it refreshed.)'
4157
+ : ''),
4158
+ )
3872
4159
  return 0
3873
4160
  }
3874
4161
  console.error(
3875
4162
  `✖ ${command} --check: ${shown} differs from what \`basalt-ui ${command}\` emits today ` +
3876
4163
  `(on disk ${onDisk.split('\n').length} lines, emitted ${content.split('\n').length}) — ` +
3877
- 're-run the same command without --check and commit the result.',
4164
+ "re-run the same command without --check and commit the result. The `@generated` header's " +
4165
+ 'provenance line is excluded from this comparison, so a version bump alone never trips it.',
3878
4166
  )
3879
4167
  return 1
3880
4168
  }
@@ -4118,9 +4406,69 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
4118
4406
  return 0
4119
4407
  }
4120
4408
 
4409
+ /**
4410
+ * Every flag each subcommand accepts — the table the unknown-flag gate reads.
4411
+ *
4412
+ * An unrecognized flag used to be SILENTLY IGNORED and the command exited 0: `check-theme
4413
+ * --audit-allow` scanned normally and reported success, `doctor --json` printed the human report.
4414
+ * That is the same fail-open shape `--version` had, and it is worse on a CLI agents drive
4415
+ * programmatically, where a typo'd gate reads as a passing gate.
4416
+ */
4417
+ const COMMAND_FLAGS: Record<string, readonly string[]> = {
4418
+ init: ['--with-router', '--with-query', '--merge-lint'],
4419
+ sync: ['--force', '--check', '--tokens-only', '--framework'],
4420
+ 'check-theme': ['--audit-allows', '--tokens-only', '--framework'],
4421
+ 'check-coverage': [],
4422
+ info: ['--json'],
4423
+ doctor: ['--tokens-only', '--framework'],
4424
+ 'guard-hook': [],
4425
+ 'tokens:css': [
4426
+ '--out',
4427
+ '--check',
4428
+ '--selector-attribute',
4429
+ '--selector-class',
4430
+ '--light-class',
4431
+ '--dark-value',
4432
+ '--light-value',
4433
+ '--default-scheme',
4434
+ '--media-fallback',
4435
+ '--only',
4436
+ '--no-legacy-aliases',
4437
+ ],
4438
+ 'fonts:css': ['--out', '--check'],
4439
+ }
4440
+
4441
+ /** Flags whose VALUE is the next argv token — skipped by the gate so `--out dist/x.css` does not
4442
+ * read `dist/x.css` as a second flag. */
4443
+ const VALUE_FLAGS = new Set([
4444
+ '--out',
4445
+ '--selector-attribute',
4446
+ '--selector-class',
4447
+ '--light-class',
4448
+ '--dark-value',
4449
+ '--light-value',
4450
+ '--default-scheme',
4451
+ '--only',
4452
+ ])
4453
+
4454
+ /** The first flag `cmd` does not accept, or null. Unknown commands return null — the dispatcher's
4455
+ * default branch already names those. */
4456
+ function unknownFlag(cmd: string, flags: readonly string[]): string | null {
4457
+ const allowed = COMMAND_FLAGS[cmd]
4458
+ if (allowed === undefined) return null
4459
+ for (let i = 0; i < flags.length; i++) {
4460
+ const flag = flags[i] as string
4461
+ if (!flag.startsWith('-')) continue
4462
+ if (!allowed.includes(flag)) return flag
4463
+ if (VALUE_FLAGS.has(flag)) i++
4464
+ }
4465
+ return null
4466
+ }
4467
+
4121
4468
  /** The one usage string — printed by `basalt help` / `--help` / `-h` AND the unknown-command fallback. */
4122
4469
  const USAGE =
4123
- 'Usage: basalt-ui <init [--with-router] [--with-query] [--merge-lint] | sync [--force] [--check] |\n' +
4470
+ 'Usage: basalt-ui <--version | init [--with-router] [--with-query] [--merge-lint] |\n' +
4471
+ ' sync [--force] [--check] [--tokens-only|--framework] |\n' +
4124
4472
  ' check-theme [--audit-allows] | check-coverage | info [--json] |\n' +
4125
4473
  ' doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\n' +
4126
4474
  'check-theme [--tokens-only|--framework] [--audit-allows]\n' +
@@ -4143,10 +4491,17 @@ const USAGE =
4143
4491
  'doctor [--tokens-only|--framework]\n' +
4144
4492
  ' Auto-detects a tokens-only consumer (no manifest + no @mantine/core) and checks only what\n' +
4145
4493
  ' applies. A check that cannot RUN is reported as SKIPPED and exits non-zero.\n\n' +
4146
- 'check-theme / doctor honour BASALT_CWD, and relocate to the single workspace package carrying a\n' +
4147
- 'basalt config when invoked from a repo root that has none — several carrying one is ambiguous\n' +
4148
- 'and exits 1 in BOTH. --tokens-only and --framework are alternatives; passing both is an error.\n\n' +
4149
- 'Every subcommand accepts --help / -h to print this message and exit without running.'
4494
+ 'sync [--force] [--check] [--tokens-only|--framework]\n' +
4495
+ ' Refreshes an EXISTING install; it never creates one (that is init). A tokens-only consumer\n' +
4496
+ ' has no scaffold to reconcile, so sync reports n/a and exits 0 there — as doctor does.\n\n' +
4497
+ 'check-theme / doctor / sync all resolve their project the same way: they honour BASALT_CWD, and\n' +
4498
+ 'relocate to the single package below the cwd carrying a basalt config when invoked from a repo\n' +
4499
+ 'root that has none — several carrying one is ambiguous and exits 1 in ALL THREE.\n' +
4500
+ '--tokens-only and --framework are alternatives; passing both is an error.\n\n' +
4501
+ 'Every subcommand accepts --help / -h to print this message and exit without running.\n' +
4502
+ '--version / -v prints the resolved package version and exits 0 — the one command that proves\n' +
4503
+ 'WHICH basalt-ui ran, which a `bunx` cache can otherwise make a lie. An unknown command or an\n' +
4504
+ 'unrecognized flag exits 1 and names what it did not understand; neither is ever ignored.'
4150
4505
 
4151
4506
  /**
4152
4507
  * CLI dispatcher — parses argv (subcommand + flags) and returns the command's exit code. The bin
@@ -4171,6 +4526,24 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
4171
4526
  console.log(USAGE)
4172
4527
  return 0
4173
4528
  }
4529
+ // Before dispatch, for the same reason --help is: this answers "which basalt-ui is running" and
4530
+ // must never run a command to do it. `bunx` does not re-resolve a cached package, so a consumer
4531
+ // can pin 1.23.0, invoke `bunx basalt-ui`, and be gated by a months-old copy — the version is the
4532
+ // only thing that catches it, and it has to be one bare line an agent can compare.
4533
+ if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
4534
+ console.log(readFrameworkVersion(packageRoot()))
4535
+ return 0
4536
+ }
4537
+ if (cmd !== undefined) {
4538
+ const bad = unknownFlag(cmd, flags)
4539
+ if (bad !== null) {
4540
+ console.error(
4541
+ `basalt-ui ${cmd}: unrecognized flag '${bad}'. ` +
4542
+ `Accepted: ${(COMMAND_FLAGS[cmd] ?? []).join(' ') || '(none)'} --help\n`,
4543
+ )
4544
+ return 1
4545
+ }
4546
+ }
4174
4547
  switch (cmd) {
4175
4548
  case 'init':
4176
4549
  return init(cwd, {
@@ -4179,7 +4552,10 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
4179
4552
  mergeLint: flags.includes('--merge-lint'),
4180
4553
  })
4181
4554
  case 'sync':
4182
- return sync({ force: flags.includes('--force'), check: flags.includes('--check') }, cwd)
4555
+ return sync(
4556
+ { force: flags.includes('--force'), check: flags.includes('--check'), flags },
4557
+ cwd,
4558
+ )
4183
4559
  case 'check-theme':
4184
4560
  return checkTheme(cwd, flags)
4185
4561
  case 'check-coverage':
@@ -4195,6 +4571,14 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
4195
4571
  case 'fonts:css':
4196
4572
  return fontsCss(flags, cwd)
4197
4573
  default:
4574
+ // Named, not just implied by a usage dump: a full-screen help block with no error line reads
4575
+ // like the command ran and chose to print help. Reported by a consumer who passed
4576
+ // `"check-theme --audit-allows"` as ONE argument and could not tell what had been rejected.
4577
+ console.error(
4578
+ cmd === undefined
4579
+ ? 'basalt-ui: no command given.\n'
4580
+ : `basalt-ui: unknown command '${cmd}'.\n`,
4581
+ )
4198
4582
  console.error(USAGE)
4199
4583
  return 1
4200
4584
  }