basalt-ui 1.23.0 → 1.24.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 (81) hide show
  1. package/AGENTS.md +31 -17
  2. package/CHANGELOG.md +23 -0
  3. package/MIGRATING.md +222 -6
  4. package/README.md +159 -49
  5. package/agent/rules/basalt-app.md +8 -2
  6. package/agent/rules/basalt-charts.md +38 -2
  7. package/agent/rules/basalt-content.md +3 -3
  8. package/agent/rules/basalt-data.md +45 -13
  9. package/agent/rules/basalt-mantine.md +4 -4
  10. package/agent/rules/basalt-query.md +57 -0
  11. package/agent/rules/basalt-tokens.md +18 -5
  12. package/agent/skills/basalt-app/SKILL.md +32 -12
  13. package/agent/skills/basalt-charts/SKILL.md +4 -2
  14. package/agent/templates/CLAUDE-block.md.tpl +3 -2
  15. package/dist/charts/kinds/BandStrip.d.ts +20 -6
  16. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  17. package/dist/charts/kinds/BandStrip.js +59 -6
  18. package/dist/charts/kinds/MirroredBars.d.ts +4 -2
  19. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  20. package/dist/charts/kinds/MirroredBars.js +13 -0
  21. package/dist/cli/index.d.ts +4 -2
  22. package/dist/cli/index.d.ts.map +1 -1
  23. package/dist/cli/index.js +269 -27
  24. package/dist/dashboard/empty-state.d.ts +6 -2
  25. package/dist/dashboard/empty-state.d.ts.map +1 -1
  26. package/dist/dashboard/empty-state.js +1 -1
  27. package/dist/dashboard/index.d.ts +1 -0
  28. package/dist/dashboard/index.d.ts.map +1 -1
  29. package/dist/dashboard/index.js +8 -0
  30. package/dist/dashboard/query-state.d.ts +96 -0
  31. package/dist/dashboard/query-state.d.ts.map +1 -0
  32. package/dist/dashboard/query-state.js +123 -0
  33. package/dist/dashboard/query-state.test.d.ts +2 -0
  34. package/dist/dashboard/query-state.test.d.ts.map +1 -0
  35. package/dist/data/data-table.d.ts +61 -4
  36. package/dist/data/data-table.d.ts.map +1 -1
  37. package/dist/data/data-table.js +41 -4
  38. package/dist/data/data-table.test.d.ts +2 -0
  39. package/dist/data/data-table.test.d.ts.map +1 -0
  40. package/dist/data/table.d.ts +1 -1
  41. package/dist/data/table.d.ts.map +1 -1
  42. package/dist/guard/index.d.ts.map +1 -1
  43. package/dist/guard/index.js +50 -9
  44. package/dist/index.d.ts +1 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +7 -1
  47. package/dist/query/error-message.d.ts +41 -0
  48. package/dist/query/error-message.d.ts.map +1 -0
  49. package/dist/query/error-message.js +30 -0
  50. package/dist/query/error-message.test.d.ts +2 -0
  51. package/dist/query/error-message.test.d.ts.map +1 -0
  52. package/dist/query/index.d.ts +1 -19
  53. package/dist/query/index.d.ts.map +1 -1
  54. package/dist/query/index.js +2 -14
  55. package/dist/surfaces.d.ts +1 -1
  56. package/dist/surfaces.js +1 -1
  57. package/llms.txt +3 -3
  58. package/package.json +1 -1
  59. package/src/charts/kinds/BandStrip.test.tsx +87 -5
  60. package/src/charts/kinds/BandStrip.tsx +105 -12
  61. package/src/charts/kinds/MirroredBars.test.tsx +39 -0
  62. package/src/charts/kinds/MirroredBars.tsx +38 -2
  63. package/src/cli/check-theme.test.ts +34 -0
  64. package/src/cli/help.test.ts +74 -1
  65. package/src/cli/index.ts +496 -41
  66. package/src/cli/tokens-css.test.ts +129 -0
  67. package/src/cli/toolchain-wiring.test.ts +146 -0
  68. package/src/dashboard/empty-state.tsx +9 -3
  69. package/src/dashboard/index.ts +11 -0
  70. package/src/dashboard/query-state.test.tsx +152 -0
  71. package/src/dashboard/query-state.tsx +272 -0
  72. package/src/data/data-table.test.tsx +158 -0
  73. package/src/data/data-table.tsx +123 -5
  74. package/src/data/table.ts +6 -1
  75. package/src/guard/check-source.test.ts +214 -0
  76. package/src/guard/index.ts +169 -15
  77. package/src/index.ts +9 -0
  78. package/src/query/error-message.test.ts +67 -0
  79. package/src/query/error-message.ts +70 -0
  80. package/src/query/index.ts +2 -34
  81. package/src/surfaces.ts +1 -1
package/src/cli/index.ts CHANGED
@@ -365,8 +365,14 @@ function readBasaltConfig(cwd: string): BasaltConfig {
365
365
  * Extensions the walker collects. `html?`/`webmanifest` close argo's finding that raw hex ships in
366
366
  * `index.html`'s `theme-color` and the webmanifest's `theme_color`/`background_color` — the two
367
367
  * places whose colors nothing re-derives on a scheme change, and which the scan never reached.
368
+ *
369
+ * `astro`/`jsx`/`vue` close the same gap one component layer up. rollhook's marketing site is
370
+ * Astro: `apps/marketing/src` holds two `.astro` templates that are its ENTIRE markup layer, and
371
+ * check-theme reported a clean 4-file scan without ever opening either. The colour and typography
372
+ * kinds apply to them exactly as they do to `.html`, which was already scanned; `.jsx` and `.vue`
373
+ * were missing from the same regex, so a plain-JS or Vue consumer was unguarded end to end.
368
374
  */
369
- const SCANNABLE_EXT = /\.(?:tsx?|css|html?|webmanifest)$/
375
+ const SCANNABLE_EXT = /\.(?:tsx?|jsx|astro|vue|css|html?|webmanifest)$/
370
376
 
371
377
  /**
372
378
  * Recursively collect scannable files under a root, skipping dependency/build dirs. Node+Bun-safe.
@@ -539,6 +545,36 @@ function descendantProjects(cwd: string, depth = 2): string[] {
539
545
  return out
540
546
  }
541
547
 
548
+ /**
549
+ * The nearest ANCESTOR of `cwd`, within the same repo, that is a basalt project — or null.
550
+ *
551
+ * The descend direction was handled and announced; ascend was not, and it fabricated instead. From
552
+ * `apps/demo` in a two-root repo, `readBasaltConfig` answered `{}`, `resolveRoots` substituted the
553
+ * built-in `['src']`, and check-theme scanned 22 of the repo's 44 guarded files, reported
554
+ * `✓ no off-palette colors`, and printed the invention back as `basalt.roots (src)`. Its
555
+ * `--audit-allows` then reported `0 live` for a repo carrying a live waiver — a clean slate from
556
+ * the one command whose entire job is to deny one. `doctor`, standing in that same directory,
557
+ * already resolved the parent install and named it, so the two commands disagreed about the same
558
+ * repo.
559
+ *
560
+ * Bounded by {@link findRepoRoot}, inclusive: an ancestor outside the repo is not this project's
561
+ * config. `repoRoot === cwd` means either cwd IS the root (nothing above it to ascend to) or there
562
+ * is no `.git` anywhere (no bound to walk within) — both answer null, which leaves a standalone
563
+ * unconfigured consumer on the built-in defaults exactly as before.
564
+ */
565
+ function ascendantProject(cwd: string): string | null {
566
+ const repoRoot = findRepoRoot(cwd)
567
+ if (repoRoot === cwd) return null
568
+ let current = dirname(cwd)
569
+ for (;;) {
570
+ if (hasBasaltProject(current)) return current
571
+ if (current === repoRoot) return null
572
+ const parent = dirname(current)
573
+ if (parent === current) return null
574
+ current = parent
575
+ }
576
+ }
577
+
542
578
  function resolveProjectDir(cwd: string): ProjectResolution {
543
579
  const override = process.env['BASALT_CWD']
544
580
  if (override !== undefined && override.length > 0) {
@@ -558,6 +594,13 @@ function resolveProjectDir(cwd: string): ProjectResolution {
558
594
  return { dir: candidates[0] as string, relocatedFrom: cwd, ambiguous: null }
559
595
  }
560
596
  if (candidates.length > 1) return { dir: cwd, relocatedFrom: null, ambiguous: candidates }
597
+
598
+ // Nothing at cwd and nothing below it: look UP before falling back to a default. See
599
+ // {@link ascendantProject} — the fabricated `roots: ['src']` this replaces is what let a scan
600
+ // pass over half a repo and an audit report zero waivers for a repo that has one.
601
+ const above = ascendantProject(cwd)
602
+ if (above !== null) return { dir: above, relocatedFrom: cwd, ambiguous: null }
603
+
561
604
  return { dir: cwd, relocatedFrom: null, ambiguous: null }
562
605
  }
563
606
 
@@ -1352,10 +1395,25 @@ function readFrameworkVersion(pkgRoot: string): string {
1352
1395
  }
1353
1396
  }
1354
1397
 
1398
+ /**
1399
+ * How a seeded script, a seeded CI step, or a piece of doctor advice should invoke the CLI.
1400
+ *
1401
+ * The LOCAL bin wherever basalt actually resolved; `bunx basalt-ui` only as the pre-install
1402
+ * fallback. `bunx` does not re-resolve a package it has cached, so a seeded `bunx basalt-ui` lets a
1403
+ * consumer pin one version and be gated by another — which is exactly what happened across three
1404
+ * consumer repos, in CI, in package.json scripts and in a `.claude` PreToolUse hook, all of them
1405
+ * placed here. Every emitted invocation goes through this function so there is one place for it to
1406
+ * be right.
1407
+ */
1408
+ function basaltBinCommand(install: BasaltInstall, cwd: string): string {
1409
+ if (install.dir === null) return 'bunx basalt-ui'
1410
+ const binDir = resolve(install.dir, '..', '.bin')
1411
+ return `${relativePosix(cwd, binDir).replace(/^\.\//, '')}/basalt-ui`
1412
+ }
1413
+
1355
1414
  /** Build the template-variable map from the framework root + consumer cwd/config. */
1356
1415
  function buildTemplateVars(pkgRoot: string, cwd: string, cfg: BasaltConfig): TemplateVars {
1357
1416
  const install = findBasaltInstall(cwd)
1358
- const binDir = install.dir === null ? null : resolve(install.dir, '..', '.bin')
1359
1417
  return {
1360
1418
  APP_NAME: readPackageName(cwd),
1361
1419
  BASALT_VERSION: readFrameworkVersion(pkgRoot),
@@ -1364,10 +1422,7 @@ function buildTemplateVars(pkgRoot: string, cwd: string, cfg: BasaltConfig): Tem
1364
1422
  ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(' '),
1365
1423
  OXLINT_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/oxlint.json'),
1366
1424
  LEFTHOOK_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/lefthook.yml'),
1367
- BASALT_BIN:
1368
- binDir === null
1369
- ? 'bunx basalt-ui'
1370
- : `${relativePosix(cwd, binDir).replace(/^\.\//, '')}/basalt-ui`,
1425
+ BASALT_BIN: basaltBinCommand(install, cwd),
1371
1426
  }
1372
1427
  }
1373
1428
 
@@ -2542,7 +2597,7 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
2542
2597
  ` "PreToolUse": [\n` +
2543
2598
  ` {\n` +
2544
2599
  ` "matcher": "Write|Edit|MultiEdit",\n` +
2545
- ` "hooks": [{ "type": "command", "command": "bunx basalt-ui guard-hook" }]\n` +
2600
+ ` "hooks": [{ "type": "command", "command": "${ctx.vars.BASALT_BIN} guard-hook" }]\n` +
2546
2601
  ` }\n` +
2547
2602
  ` ]\n` +
2548
2603
  ` }`,
@@ -2603,6 +2658,21 @@ type SyncOptions = {
2603
2658
  * install lives at the repo root above it. Nothing walks up, so `sync` read "no manifest here" as
2604
2659
  * "nothing scaffolded yet" and wrote a complete second consumer beside the real one.
2605
2660
  */
2661
+ /**
2662
+ * The sentence `sync` prints when the cwd carries no install but an ancestor does.
2663
+ *
2664
+ * Shared with `doctor` deliberately. The two commands already share `resolveProjectDir`, but not
2665
+ * the ADVICE: `sync` named the parent install (1.22.0's "stop scaffolding a second consumer" fix)
2666
+ * while `doctor`, in the same directory, kept prescribing `basalt-ui init` — so following doctor
2667
+ * literally performed the exact mistake that fix exists to prevent. One function, one sentence.
2668
+ */
2669
+ function parentInstallAdvice(cwd: string, parent: string, verb: string): string {
2670
+ return (
2671
+ `This repo's install is at ${relativePosix(cwd, parent)} (${parent}) — run ${verb} there, ` +
2672
+ 'or set BASALT_CWD to it.'
2673
+ )
2674
+ }
2675
+
2606
2676
  function findManifestAbove(dir: string): string | null {
2607
2677
  let current = resolve(dir, '..')
2608
2678
  for (;;) {
@@ -2681,8 +2751,7 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
2681
2751
  (elsewhere === null
2682
2752
  ? ' No manifest exists at any ancestor either — run `basalt-ui init` here if this ' +
2683
2753
  'package really is meant to be a consumer.'
2684
- : ` This repo's install is at ${relativePosix(cwd, elsewhere)} (${elsewhere}) — run ` +
2685
- 'sync there, or set BASALT_CWD to it.'),
2754
+ : ` ${parentInstallAdvice(cwd, elsewhere, 'sync')}`),
2686
2755
  )
2687
2756
  return 1
2688
2757
  }
@@ -3299,8 +3368,10 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
3299
3368
  * demonstrably not the installed package — see the check).
3300
3369
  * 5. The running CLI's own version matches the installed basalt-ui (catches a stale
3301
3370
  * `bunx basalt-ui` npm fetch; best-effort, skipped if node_modules is absent).
3302
- * 9. `basaltAppPlugin`'s (basalt-ui/vite) default icon filenames exist under public/ (only when
3303
- * a public/ dir exists at all — skipped otherwise).
3371
+ * 9. The icon files `basaltAppPlugin` (basalt-ui/vite) references exist under the app package's
3372
+ * public/ — the package found via `basalt.roots`, not just cwd, so a root-invoked run still
3373
+ * reaches it. Never silently omitted: no plugin call anywhere is a pass that says so, a
3374
+ * plugin call with no public/ beside it is a SKIPPED.
3304
3375
  * 10 (second half). A declared `basalt.aiMajorSkewReason` when the ai majors currently AGREE — the
3305
3376
  * exemption is stale and can be deleted; an exemption nobody revisits is how a real, later
3306
3377
  * skew slips through unnoticed.
@@ -3365,6 +3436,10 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
3365
3436
  // ── Profile ────────────────────────────────────────────────────────────────
3366
3437
  const manifestAbs = resolve(cwd, MANIFEST_PATH)
3367
3438
  const manifestExists = existsSync(manifestAbs)
3439
+ // Non-null ⇒ this directory is a package UNDER a configured repo, not an unscaffolded consumer.
3440
+ // Every remedy below that would otherwise say "run `basalt-ui init`" points at the parent
3441
+ // instead — see {@link parentInstallAdvice}.
3442
+ const parentInstall = manifestExists ? null : findManifestAbove(cwd)
3368
3443
  const profile = inferredProfile(cwd, cfg, flags)
3369
3444
  if (profile === 'tokens-only') {
3370
3445
  lines.push(
@@ -3388,6 +3463,12 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
3388
3463
  pass(`${MANIFEST_PATH}: n/a — a tokens-only consumer has no scaffold to reconcile`)
3389
3464
  } else if (manifestExists) {
3390
3465
  pass(`${MANIFEST_PATH} exists`)
3466
+ } else if (parentInstall !== null) {
3467
+ fail(
3468
+ `${MANIFEST_PATH} missing here, but this is not an unscaffolded consumer. ` +
3469
+ `${parentInstallAdvice(cwd, parentInstall, 'doctor')} \`basalt-ui init\` is NOT the fix: ` +
3470
+ 'it would scaffold a SECOND consumer beside the real one.',
3471
+ )
3391
3472
  } else {
3392
3473
  fail(`${MANIFEST_PATH} missing — run \`basalt-ui init\` to scaffold the consumer repo`)
3393
3474
  }
@@ -3549,7 +3630,11 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
3549
3630
  if (oxlintrcRaw === null) {
3550
3631
  fail(
3551
3632
  '.oxlintrc.json missing — the shipped oxlint preset (the basalt/* design rules) is not ' +
3552
- 'active. Run `basalt-ui init` to seed it.',
3633
+ 'active. ' +
3634
+ (parentInstall === null
3635
+ ? 'Run `basalt-ui init` to seed it.'
3636
+ : `${parentInstallAdvice(cwd, parentInstall, 'doctor')} The preset is seeded once, at ` +
3637
+ 'the install — not per package.'),
3553
3638
  )
3554
3639
  } else {
3555
3640
  // JSONC: oxlint accepts comments and real consumer configs carry them.
@@ -3642,40 +3727,81 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
3642
3727
  `${lefthookGate.file}: \`lefthook dump\` resolves the merged config and NO pre-commit ` +
3643
3728
  'command runs check-theme — the theme guard is not gating commits. Either add ' +
3644
3729
  `"extends: [${lefthookCorrect}]", or add your own command running ` +
3645
- '`bunx basalt-ui check-theme` (with `root:` if it must run in a subdirectory).',
3730
+ `\`${basaltBinCommand(install, cwd)} check-theme\` (with \`root:\` if it must run in a ` +
3731
+ 'subdirectory).',
3646
3732
  )
3647
3733
  } else {
3648
3734
  warn(
3649
3735
  `${lefthookGate.file} names no basalt gate and \`lefthook dump\` could not be run here, so ` +
3650
3736
  'this check cannot tell a missing gate from one wired through an `include:` or a remote ' +
3651
3737
  'config it never read — treat it as advisory. If the guard really is unwired, add ' +
3652
- `"extends: [${lefthookCorrect}]" or your own command running \`bunx basalt-ui check-theme\`.`,
3738
+ `"extends: [${lefthookCorrect}]" or your own command running ` +
3739
+ `\`${basaltBinCommand(install, cwd)} check-theme\`.`,
3653
3740
  )
3654
3741
  }
3655
3742
 
3656
- // ── Warn check 8: basaltAppPlugin's default icon files exist in public/ ────
3657
- // basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references these filenames by
3658
- // default (the realfavicongenerator convention). Only runs when a public/ dir exists at all —
3659
- // apps that don't use the plugin, or don't use Vite's public-dir convention, get no false
3660
- // warning. Best-effort: a custom `icons.dir` isn't visible here, so this checks the root only.
3661
- const publicDir = resolve(cwd, 'public')
3662
- if (profile === 'framework' && existsSync(publicDir)) {
3663
- const iconFiles = [
3664
- 'favicon.ico',
3665
- 'favicon.svg',
3666
- 'favicon-96x96.png',
3667
- 'apple-touch-icon.png',
3668
- 'web-app-manifest-192x192.png',
3669
- 'web-app-manifest-512x512.png',
3670
- ]
3671
- const missingIcons = iconFiles.filter((f) => !existsSync(resolve(publicDir, f)))
3672
- if (missingIcons.length > 0) {
3673
- warn(
3674
- `public/ is missing basaltAppPlugin icon file(s): ${missingIcons.join(', ')} — generate ` +
3675
- 'them (e.g. via realfavicongenerator.net) and place them at the public/ root.',
3743
+ // ── Warn check 8: the icon files basaltAppPlugin actually references exist ────
3744
+ // basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references six filenames by default
3745
+ // (the realfavicongenerator convention), but since 1.23.0 an app can NAME its own — so the option
3746
+ // is read (see {@link readAppIconsOption}) rather than assumed.
3747
+ //
3748
+ // It used to read `cwd` alone, and in a monorepo the vite config lives in the app PACKAGE while
3749
+ // the only invocation that exits 0 is the repo root. The check therefore vanished from the root
3750
+ // run with no line at all — the failure mode SKIPPED exists to eliminate — leaving no single
3751
+ // invocation that both passes and checks the icons. It now resolves the app package the same way
3752
+ // the scan does, off `basalt.roots`; see {@link findAppPluginDir}.
3753
+ if (profile === 'framework') {
3754
+ const appDir = findAppPluginDir(cwd, cfg)
3755
+ const iconsIn = appDir ?? cwd
3756
+ const icons = readAppIconsOption(iconsIn)
3757
+ const here = appDir === null || appDir === cwd ? '' : `${relativePosix(cwd, appDir)}/`
3758
+ const publicDir = resolve(iconsIn, 'public')
3759
+ if (appDir === null && !existsSync(publicDir)) {
3760
+ // No `basaltAppPlugin(` anywhere under the declared roots and no public/ here: nothing in
3761
+ // this project references icon files, so there is nothing to check — and saying so is the
3762
+ // point. A check that vanishes without a word is indistinguishable from one that passed.
3763
+ pass('app icons: no `basaltAppPlugin(` under this project and no public/ — nothing to check')
3764
+ } else if (icons.kind === 'none') {
3765
+ pass('basaltAppPlugin is configured with no icons — nothing to check')
3766
+ } else if (!existsSync(publicDir)) {
3767
+ skip(
3768
+ `app icons — ${here === '' ? 'this package' : here.replace(/\/$/, '')} configures ` +
3769
+ 'basaltAppPlugin, but there is no `public/` beside its vite config, so the files it ' +
3770
+ "references cannot be located. Use Vite's public-dir convention, or set `icons: false` " +
3771
+ 'if this app ships none.',
3676
3772
  )
3773
+ } else if (icons.kind === 'explicit') {
3774
+ // The app NAMED its icons, so those are the only files it owes. Checking the six defaults
3775
+ // here is what told 1.23.0's flagship adopter to generate five files it deliberately lacks.
3776
+ const missing = icons.sources.filter(
3777
+ (src) => !existsSync(resolve(publicDir, src.replace(/^\//, ''))),
3778
+ )
3779
+ if (missing.length > 0) {
3780
+ warn(
3781
+ `basaltAppPlugin declares icon(s) that are not in ${here}public/: ${missing.join(', ')} — ` +
3782
+ 'the manifest and the head links will point at a 404. Place them, or drop them from ' +
3783
+ 'the `icons` array.',
3784
+ )
3785
+ } else {
3786
+ pass(
3787
+ `${here}public/ has all ${icons.sources.length} icon file(s) basaltAppPlugin's ` +
3788
+ '`icons` option names',
3789
+ )
3790
+ }
3677
3791
  } else {
3678
- pass("public/ has all of basaltAppPlugin's default icon files")
3792
+ const dir = icons.kind === 'default' ? icons.dir : null
3793
+ const iconRoot = dir === null ? publicDir : resolve(publicDir, dir.replace(/^\//, ''))
3794
+ const shown = dir === null ? `${here}public/` : `${here}public/${dir.replace(/^\//, '')}`
3795
+ const missingIcons = DEFAULT_APP_ICON_FILES.filter((f) => !existsSync(resolve(iconRoot, f)))
3796
+ if (missingIcons.length > 0) {
3797
+ warn(
3798
+ `${shown} is missing basaltAppPlugin icon file(s): ${missingIcons.join(', ')} — generate ` +
3799
+ `them (e.g. via realfavicongenerator.net) and place them at the ${shown} root, or name ` +
3800
+ "the icons this app really has via the plugin's `icons` array.",
3801
+ )
3802
+ } else {
3803
+ pass(`${shown} has all of basaltAppPlugin's default icon files`)
3804
+ }
3679
3805
  }
3680
3806
  }
3681
3807
 
@@ -3882,6 +4008,137 @@ export function info(flags: string[]): number {
3882
4008
  return 0
3883
4009
  }
3884
4010
 
4011
+ /** The six filenames `basaltAppPlugin` references when `icons` is left at its default. */
4012
+ const DEFAULT_APP_ICON_FILES = [
4013
+ 'favicon.ico',
4014
+ 'favicon.svg',
4015
+ 'favicon-96x96.png',
4016
+ 'apple-touch-icon.png',
4017
+ 'web-app-manifest-192x192.png',
4018
+ 'web-app-manifest-512x512.png',
4019
+ ] as const
4020
+
4021
+ const VITE_CONFIG_FILES = [
4022
+ 'vite.config.ts',
4023
+ 'vite.config.mts',
4024
+ 'vite.config.cts',
4025
+ 'vite.config.js',
4026
+ 'vite.config.mjs',
4027
+ 'vite.config.cjs',
4028
+ ] as const
4029
+
4030
+ /**
4031
+ * The directory whose vite config actually calls `basaltAppPlugin(` — cwd, or the app package the
4032
+ * declared roots point into. Null when no vite config under this project calls it.
4033
+ *
4034
+ * `basalt.roots: ["apps/web/src"]` already names where the app is; the icon check just never used
4035
+ * it, so from the repo root — the only invocation that can exit 0 on a monorepo layout — it found
4036
+ * no vite config and silently emitted nothing. Each root is walked from its own directory up to
4037
+ * cwd, nearest first, so `apps/web/src` resolves to `apps/web`, the package that owns both the
4038
+ * vite config and the `public/` tree.
4039
+ */
4040
+ function findAppPluginDir(cwd: string, cfg: BasaltConfig): string | null {
4041
+ const seen = new Set<string>()
4042
+ const candidates: string[] = []
4043
+ const add = (dir: string): void => {
4044
+ if (seen.has(dir)) return
4045
+ seen.add(dir)
4046
+ candidates.push(dir)
4047
+ }
4048
+ add(cwd)
4049
+ for (const root of resolveRoots(cfg)) {
4050
+ let current = resolve(cwd, root)
4051
+ // A root that escapes cwd (`../shared`) is not this project's app package — never walk out.
4052
+ while (current !== cwd && !relative(cwd, current).startsWith('..')) {
4053
+ add(current)
4054
+ const parent = dirname(current)
4055
+ if (parent === current) break
4056
+ current = parent
4057
+ }
4058
+ }
4059
+ for (const dir of candidates) {
4060
+ const configPath = VITE_CONFIG_FILES.map((f) => resolve(dir, f)).find((f) => existsSync(f))
4061
+ if (configPath === undefined) continue
4062
+ if (readIfExists(configPath)?.includes('basaltAppPlugin(') === true) return dir
4063
+ }
4064
+ return null
4065
+ }
4066
+
4067
+ /**
4068
+ * What `basaltAppPlugin`'s `icons` option says this app actually has.
4069
+ *
4070
+ * `unknown` = no vite config, no `basaltAppPlugin(` call, or an `icons` value this bounded read
4071
+ * cannot classify — all three fall back to the six-default check, so the gate can only ever get
4072
+ * NARROWER than it was, never blinder.
4073
+ */
4074
+ type AppIconsOption =
4075
+ | { kind: 'default'; dir: string | null }
4076
+ | { kind: 'none' }
4077
+ | { kind: 'explicit'; sources: readonly string[] }
4078
+ | { kind: 'unknown' }
4079
+
4080
+ /** The balanced `(`…`)` / `{`…`}` / `[`…`]` span starting at `open`, or null if it never closes. */
4081
+ function balancedSpan(text: string, open: number): string | null {
4082
+ const pairs: Record<string, string> = { '(': ')', '{': '}', '[': ']' }
4083
+ const closer = pairs[text[open] as string]
4084
+ if (closer === undefined) return null
4085
+ let depth = 0
4086
+ for (let i = open; i < text.length; i++) {
4087
+ const ch = text[i] as string
4088
+ if (ch === text[open]) depth++
4089
+ else if (ch === closer) {
4090
+ depth--
4091
+ if (depth === 0) return text.slice(open, i + 1)
4092
+ }
4093
+ }
4094
+ return null
4095
+ }
4096
+
4097
+ /**
4098
+ * Read the `icons` option out of the consumer's vite config.
4099
+ *
4100
+ * 1.23.0 let `icons` name an app's REAL icon files; doctor's icon check kept demanding the six
4101
+ * default filenames and never read the option, so adopting the release's headline feature produced
4102
+ * a brand-new warning — on the very repo the feature was written for. A warning that fires BECAUSE
4103
+ * you adopted the new API teaches people to ignore doctor.
4104
+ *
4105
+ * doctor does not run the build, so the plugin's resolved answer is not available to it; this is a
4106
+ * bounded textual read of the config file, the same shape the guard uses on source. It is
4107
+ * deliberately conservative: anything it cannot classify returns `unknown` and the old check runs.
4108
+ */
4109
+ function readAppIconsOption(cwd: string): AppIconsOption {
4110
+ const configPath = VITE_CONFIG_FILES.map((f) => resolve(cwd, f)).find((f) => existsSync(f))
4111
+ if (configPath === undefined) return { kind: 'unknown' }
4112
+ const text = readIfExists(configPath)
4113
+ if (text === null) return { kind: 'unknown' }
4114
+ const call = text.indexOf('basaltAppPlugin(')
4115
+ if (call === -1) return { kind: 'unknown' }
4116
+ const args = balancedSpan(text, call + 'basaltAppPlugin'.length)
4117
+ if (args === null) return { kind: 'unknown' }
4118
+ const key = args.search(/\bicons\s*:/)
4119
+ if (key === -1) return { kind: 'default', dir: null }
4120
+ const valueStart = args.indexOf(':', key) + 1
4121
+ const rest = args.slice(valueStart).trimStart()
4122
+ if (rest.startsWith('false')) return { kind: 'none' }
4123
+ const offset = args.length - rest.length
4124
+ if (rest.startsWith('[')) {
4125
+ const list = balancedSpan(args, offset)
4126
+ if (list === null) return { kind: 'unknown' }
4127
+ const sources = [...list.matchAll(/\bsrc\s*:\s*['"`]([^'"`]+)['"`]/g)].map(
4128
+ (m) => m[1] as string,
4129
+ )
4130
+ // An empty array means "this app has no icons" to the plugin, same as `false`.
4131
+ return sources.length === 0 ? { kind: 'none' } : { kind: 'explicit', sources }
4132
+ }
4133
+ if (rest.startsWith('{')) {
4134
+ const obj = balancedSpan(args, offset)
4135
+ if (obj === null) return { kind: 'unknown' }
4136
+ const dir = /\bdir\s*:\s*['"`]([^'"`]+)['"`]/.exec(obj)
4137
+ return { kind: 'default', dir: dir === null ? null : (dir[1] as string) }
4138
+ }
4139
+ return { kind: 'unknown' }
4140
+ }
4141
+
3885
4142
  /** Read the value that follows a `--flag` in an argv slice; `undefined` when absent or terminal. */
3886
4143
  function flagValue(flags: string[], name: string): string | undefined {
3887
4144
  const i = flags.indexOf(name)
@@ -3904,6 +4161,84 @@ function generatedHeader(version: string, command: string, flags: string[]): str
3904
4161
  return `${GENERATED_HEADER_LINE}\n/* basalt-ui ${version} — \`basalt-ui ${invocation}\` */\n`
3905
4162
  }
3906
4163
 
4164
+ /**
4165
+ * The provenance line, split so only the VERSION token can be neutralized.
4166
+ *
4167
+ * Deliberately the same shape as the guard's `GENERATED_PROVENANCE_LINE` (`src/guard/index.ts`),
4168
+ * which is what the `@generated` exemption tests line 2 against — the two must keep agreeing, so
4169
+ * this is written to match, not to be lenient. A line that does not match is left VERBATIM, which
4170
+ * is what makes a deleted, reworded or forged header fail `--check` instead of being blanked into
4171
+ * agreement.
4172
+ */
4173
+ const PROVENANCE_VERSION =
4174
+ /^(\/\* basalt-ui )(\d+\.\d+\.\d+\S*)( — `basalt-ui (?:tokens:css|fonts:css)[^`]*` \*\/)$/
4175
+
4176
+ /** The provenance line's version token, or null when line 2 is not a provenance line at all. */
4177
+ function provenanceVersion(css: string): string | null {
4178
+ const match = PROVENANCE_VERSION.exec(css.split('\n')[1] ?? '')
4179
+ return match === null ? null : (match[2] as string)
4180
+ }
4181
+
4182
+ /**
4183
+ * The generated file with only the provenance line's VERSION TOKEN neutralized — what `--check`
4184
+ * actually compares.
4185
+ *
4186
+ * `--check` used to gate the whole file byte-for-byte, so every basalt-ui release forced a
4187
+ * mandatory no-op commit in every consumer that commits a generated sheet. 1.23.1 fixed that by
4188
+ * blanking the whole of line 2 — and line 2 is the line that carries the exact invocation, which
4189
+ * line 1 tells the reader to regenerate with. The gate stopped verifying the one instruction it
4190
+ * hands out: rollhook's header was rewritten from `--only core --no-legacy-aliases` to `--only all
4191
+ * --with-legacy-aliases` and `--check` passed clean.
4192
+ *
4193
+ * So the version token is replaced by a sentinel and the rest of the line still gates. A release
4194
+ * alone never trips the check; a changed flag, a reworded header, and a header that is simply gone
4195
+ * all do.
4196
+ *
4197
+ * The version STAYS in the file rather than being dropped, because the consumer this command exists
4198
+ * for — a framework-free site running `bunx basalt-ui tokens:css` with no basalt in its dependency
4199
+ * tree at all — has no other record of which version emitted it, and no `doctor` to ask.
4200
+ *
4201
+ * Line-aligned, never dropped, so the reported line counts still match the files on disk. The
4202
+ * `@generated` exemption is untouched by all of this: nothing here changes what
4203
+ * {@link generatedHeader} emits, and {@link PROVENANCE_VERSION} is matched to the guard's own
4204
+ * provenance regex rather than replacing it.
4205
+ */
4206
+ function withoutProvenanceVersion(css: string): string {
4207
+ const lines = css.split('\n')
4208
+ const line = lines[1]
4209
+ if (line === undefined) return css
4210
+ const match = PROVENANCE_VERSION.exec(line)
4211
+ if (match === null) return css
4212
+ lines[1] = `${match[1] as string}<version>${match[3] as string}`
4213
+ return lines.join('\n')
4214
+ }
4215
+
4216
+ /** The leading `major.minor.patch` of a version string as numbers, or null when it has none. */
4217
+ function versionTriple(version: string): number[] | null {
4218
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version)
4219
+ return match === null ? null : [Number(match[1]), Number(match[2]), Number(match[3])]
4220
+ }
4221
+
4222
+ /**
4223
+ * How the on-disk provenance version relates to the one that just ran — measured, not assumed.
4224
+ *
4225
+ * The success message asserted the file "still names an older basalt-ui" without ever reading the
4226
+ * token, so `0.0.1-nonsense` got the same sentence. Both sides are parsed; the numeric triples are
4227
+ * compared and anything that does not order cleanly is reported as merely different.
4228
+ */
4229
+ function describeVersionSkew(onDisk: string, current: string): string {
4230
+ const a = versionTriple(onDisk)
4231
+ const b = versionTriple(current)
4232
+ if (a === null || b === null) return 'a different'
4233
+ for (let i = 0; i < 3; i++) {
4234
+ const left = a[i] as number
4235
+ const right = b[i] as number
4236
+ if (left < right) return 'an older'
4237
+ if (left > right) return 'a newer'
4238
+ }
4239
+ return 'a different'
4240
+ }
4241
+
3907
4242
  /**
3908
4243
  * Space- and number-normalize the argument list of a legacy color function so the emitted file
3909
4244
  * survives a normal repo's formatter.
@@ -3966,14 +4301,46 @@ function emitGeneratedCss(content: string, flags: string[], cwd: string, command
3966
4301
  )
3967
4302
  return 1
3968
4303
  }
3969
- if (onDisk === content) {
3970
- console.log(`✓ ${command} --check: ${shown} is up to date.`)
4304
+ if (withoutProvenanceVersion(onDisk) === withoutProvenanceVersion(content)) {
4305
+ const diskVersion = provenanceVersion(onDisk)
4306
+ const currentVersion = provenanceVersion(content)
4307
+ const skew =
4308
+ diskVersion !== null && currentVersion !== null && diskVersion !== currentVersion
4309
+ ? ` (Its provenance line names basalt-ui ${diskVersion}, ` +
4310
+ `${describeVersionSkew(diskVersion, currentVersion)} release than the ` +
4311
+ `${currentVersion} that just ran — only that version token is excluded from the ` +
4312
+ 'comparison. Re-run without --check whenever you want it refreshed.)'
4313
+ : ''
4314
+ console.log(`✓ ${command} --check: ${shown} is up to date.${skew}`)
3971
4315
  return 0
3972
4316
  }
4317
+ // Name the provenance line when it is the ONLY thing that moved: the diff is one line the
4318
+ // reader is about to scroll past, and a rewritten invocation is the failure that bought this
4319
+ // branch — line 1 tells them to regenerate with line 2, so line 2 has to be true.
4320
+ const diskLines = onDisk.split('\n')
4321
+ const wantLines = content.split('\n')
4322
+ const provenanceOnly =
4323
+ diskLines.length === wantLines.length &&
4324
+ diskLines.every((line, i) => i === 1 || line === wantLines[i]) &&
4325
+ diskLines[1] !== wantLines[1]
4326
+ if (provenanceOnly) {
4327
+ console.error(
4328
+ `✖ ${command} --check: ${shown}'s \`@generated\` provenance line does not match the ` +
4329
+ 'command that produced this content — the token values agree, line 2 does not.\n' +
4330
+ ` on disk: ${diskLines[1]}\n` +
4331
+ ` emitted: ${wantLines[1]}\n` +
4332
+ 'Line 1 tells the reader to regenerate with line 2, so line 2 is gated: run the emitted ' +
4333
+ 'invocation without --check and commit the result, or change the flags this check runs ' +
4334
+ 'with to the ones the file was really built from. Only the version token is exempt.',
4335
+ )
4336
+ return 1
4337
+ }
3973
4338
  console.error(
3974
4339
  `✖ ${command} --check: ${shown} differs from what \`basalt-ui ${command}\` emits today ` +
3975
- `(on disk ${onDisk.split('\n').length} lines, emitted ${content.split('\n').length}) — ` +
3976
- 're-run the same command without --check and commit the result.',
4340
+ `(on disk ${diskLines.length} lines, emitted ${wantLines.length}) — ` +
4341
+ 're-run the same command without --check and commit the result. Only the `@generated` ' +
4342
+ "provenance line's VERSION token is excluded from this comparison, so a release alone " +
4343
+ 'never trips it — but its flags do.',
3977
4344
  )
3978
4345
  return 1
3979
4346
  }
@@ -4217,9 +4584,68 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
4217
4584
  return 0
4218
4585
  }
4219
4586
 
4587
+ /**
4588
+ * Every flag each subcommand accepts — the table the unknown-flag gate reads.
4589
+ *
4590
+ * An unrecognized flag used to be SILENTLY IGNORED and the command exited 0: `check-theme
4591
+ * --audit-allow` scanned normally and reported success, `doctor --json` printed the human report.
4592
+ * That is the same fail-open shape `--version` had, and it is worse on a CLI agents drive
4593
+ * programmatically, where a typo'd gate reads as a passing gate.
4594
+ */
4595
+ const COMMAND_FLAGS: Record<string, readonly string[]> = {
4596
+ init: ['--with-router', '--with-query', '--merge-lint'],
4597
+ sync: ['--force', '--check', '--tokens-only', '--framework'],
4598
+ 'check-theme': ['--audit-allows', '--tokens-only', '--framework'],
4599
+ 'check-coverage': [],
4600
+ info: ['--json'],
4601
+ doctor: ['--tokens-only', '--framework'],
4602
+ 'guard-hook': [],
4603
+ 'tokens:css': [
4604
+ '--out',
4605
+ '--check',
4606
+ '--selector-attribute',
4607
+ '--selector-class',
4608
+ '--light-class',
4609
+ '--dark-value',
4610
+ '--light-value',
4611
+ '--default-scheme',
4612
+ '--media-fallback',
4613
+ '--only',
4614
+ '--no-legacy-aliases',
4615
+ ],
4616
+ 'fonts:css': ['--out', '--check'],
4617
+ }
4618
+
4619
+ /** Flags whose VALUE is the next argv token — skipped by the gate so `--out dist/x.css` does not
4620
+ * read `dist/x.css` as a second flag. */
4621
+ const VALUE_FLAGS = new Set([
4622
+ '--out',
4623
+ '--selector-attribute',
4624
+ '--selector-class',
4625
+ '--light-class',
4626
+ '--dark-value',
4627
+ '--light-value',
4628
+ '--default-scheme',
4629
+ '--only',
4630
+ ])
4631
+
4632
+ /** The first flag `cmd` does not accept, or null. Unknown commands return null — the dispatcher's
4633
+ * default branch already names those. */
4634
+ function unknownFlag(cmd: string, flags: readonly string[]): string | null {
4635
+ const allowed = COMMAND_FLAGS[cmd]
4636
+ if (allowed === undefined) return null
4637
+ for (let i = 0; i < flags.length; i++) {
4638
+ const flag = flags[i] as string
4639
+ if (!flag.startsWith('-')) continue
4640
+ if (!allowed.includes(flag)) return flag
4641
+ if (VALUE_FLAGS.has(flag)) i++
4642
+ }
4643
+ return null
4644
+ }
4645
+
4220
4646
  /** The one usage string — printed by `basalt help` / `--help` / `-h` AND the unknown-command fallback. */
4221
4647
  const USAGE =
4222
- 'Usage: basalt-ui <init [--with-router] [--with-query] [--merge-lint] |\n' +
4648
+ 'Usage: basalt-ui <--version | init [--with-router] [--with-query] [--merge-lint] |\n' +
4223
4649
  ' sync [--force] [--check] [--tokens-only|--framework] |\n' +
4224
4650
  ' check-theme [--audit-allows] | check-coverage | info [--json] |\n' +
4225
4651
  ' doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\n' +
@@ -4250,7 +4676,10 @@ const USAGE =
4250
4676
  'relocate to the single package below the cwd carrying a basalt config when invoked from a repo\n' +
4251
4677
  'root that has none — several carrying one is ambiguous and exits 1 in ALL THREE.\n' +
4252
4678
  '--tokens-only and --framework are alternatives; passing both is an error.\n\n' +
4253
- 'Every subcommand accepts --help / -h to print this message and exit without running.'
4679
+ 'Every subcommand accepts --help / -h to print this message and exit without running.\n' +
4680
+ '--version / -v prints the resolved package version and exits 0 — the one command that proves\n' +
4681
+ 'WHICH basalt-ui ran, which a `bunx` cache can otherwise make a lie. An unknown command or an\n' +
4682
+ 'unrecognized flag exits 1 and names what it did not understand; neither is ever ignored.'
4254
4683
 
4255
4684
  /**
4256
4685
  * CLI dispatcher — parses argv (subcommand + flags) and returns the command's exit code. The bin
@@ -4275,6 +4704,24 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
4275
4704
  console.log(USAGE)
4276
4705
  return 0
4277
4706
  }
4707
+ // Before dispatch, for the same reason --help is: this answers "which basalt-ui is running" and
4708
+ // must never run a command to do it. `bunx` does not re-resolve a cached package, so a consumer
4709
+ // can pin 1.23.0, invoke `bunx basalt-ui`, and be gated by a months-old copy — the version is the
4710
+ // only thing that catches it, and it has to be one bare line an agent can compare.
4711
+ if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
4712
+ console.log(readFrameworkVersion(packageRoot()))
4713
+ return 0
4714
+ }
4715
+ if (cmd !== undefined) {
4716
+ const bad = unknownFlag(cmd, flags)
4717
+ if (bad !== null) {
4718
+ console.error(
4719
+ `basalt-ui ${cmd}: unrecognized flag '${bad}'. ` +
4720
+ `Accepted: ${(COMMAND_FLAGS[cmd] ?? []).join(' ') || '(none)'} --help\n`,
4721
+ )
4722
+ return 1
4723
+ }
4724
+ }
4278
4725
  switch (cmd) {
4279
4726
  case 'init':
4280
4727
  return init(cwd, {
@@ -4302,6 +4749,14 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
4302
4749
  case 'fonts:css':
4303
4750
  return fontsCss(flags, cwd)
4304
4751
  default:
4752
+ // Named, not just implied by a usage dump: a full-screen help block with no error line reads
4753
+ // like the command ran and chose to print help. Reported by a consumer who passed
4754
+ // `"check-theme --audit-allows"` as ONE argument and could not tell what had been rejected.
4755
+ console.error(
4756
+ cmd === undefined
4757
+ ? 'basalt-ui: no command given.\n'
4758
+ : `basalt-ui: unknown command '${cmd}'.\n`,
4759
+ )
4305
4760
  console.error(USAGE)
4306
4761
  return 1
4307
4762
  }