basalt-ui 1.23.1 → 1.25.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 (63) hide show
  1. package/AGENTS.md +11 -6
  2. package/CHANGELOG.md +21 -0
  3. package/MIGRATING.md +138 -4
  4. package/README.md +123 -32
  5. package/agent/rules/basalt-data.md +104 -12
  6. package/agent/rules/basalt-query.md +57 -0
  7. package/agent/skills/basalt-app/SKILL.md +7 -0
  8. package/dist/cli/index.d.ts +4 -2
  9. package/dist/cli/index.d.ts.map +1 -1
  10. package/dist/cli/index.js +101 -17
  11. package/dist/dashboard/empty-state.d.ts +6 -2
  12. package/dist/dashboard/empty-state.d.ts.map +1 -1
  13. package/dist/dashboard/empty-state.js +1 -1
  14. package/dist/dashboard/index.d.ts +1 -0
  15. package/dist/dashboard/index.d.ts.map +1 -1
  16. package/dist/dashboard/index.js +8 -0
  17. package/dist/dashboard/query-state.d.ts +96 -0
  18. package/dist/dashboard/query-state.d.ts.map +1 -0
  19. package/dist/dashboard/query-state.js +123 -0
  20. package/dist/dashboard/query-state.test.d.ts +2 -0
  21. package/dist/dashboard/query-state.test.d.ts.map +1 -0
  22. package/dist/data/data-table.d.ts +125 -8
  23. package/dist/data/data-table.d.ts.map +1 -1
  24. package/dist/data/data-table.js +121 -13
  25. package/dist/data/data-table.test.d.ts +2 -0
  26. package/dist/data/data-table.test.d.ts.map +1 -0
  27. package/dist/data/table.d.ts +2 -2
  28. package/dist/data/table.d.ts.map +1 -1
  29. package/dist/guard/index.d.ts.map +1 -1
  30. package/dist/guard/index.js +21 -6
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +7 -1
  34. package/dist/query/error-message.d.ts +41 -0
  35. package/dist/query/error-message.d.ts.map +1 -0
  36. package/dist/query/error-message.js +30 -0
  37. package/dist/query/error-message.test.d.ts +2 -0
  38. package/dist/query/error-message.test.d.ts.map +1 -0
  39. package/dist/query/index.d.ts +1 -19
  40. package/dist/query/index.d.ts.map +1 -1
  41. package/dist/query/index.js +2 -14
  42. package/dist/surfaces.d.ts +1 -1
  43. package/dist/surfaces.js +1 -1
  44. package/llms.txt +3 -3
  45. package/package.json +1 -1
  46. package/src/cli/check-theme.test.ts +34 -0
  47. package/src/cli/index.ts +222 -44
  48. package/src/cli/tokens-css.test.ts +73 -6
  49. package/src/cli/toolchain-wiring.test.ts +146 -0
  50. package/src/dashboard/empty-state.tsx +9 -3
  51. package/src/dashboard/index.ts +11 -0
  52. package/src/dashboard/query-state.test.tsx +152 -0
  53. package/src/dashboard/query-state.tsx +272 -0
  54. package/src/data/data-table.test.tsx +349 -0
  55. package/src/data/data-table.tsx +359 -42
  56. package/src/data/table.ts +8 -1
  57. package/src/guard/check-source.test.ts +141 -0
  58. package/src/guard/index.ts +102 -8
  59. package/src/index.ts +9 -0
  60. package/src/query/error-message.test.ts +67 -0
  61. package/src/query/error-message.ts +70 -0
  62. package/src/query/index.ts +2 -34
  63. 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
 
@@ -3325,8 +3368,10 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
3325
3368
  * demonstrably not the installed package — see the check).
3326
3369
  * 5. The running CLI's own version matches the installed basalt-ui (catches a stale
3327
3370
  * `bunx basalt-ui` npm fetch; best-effort, skipped if node_modules is absent).
3328
- * 9. `basaltAppPlugin`'s (basalt-ui/vite) default icon filenames exist under public/ (only when
3329
- * 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.
3330
3375
  * 10 (second half). A declared `basalt.aiMajorSkewReason` when the ai majors currently AGREE — the
3331
3376
  * exemption is stale and can be deleted; an exemption nobody revisits is how a real, later
3332
3377
  * skew slips through unnoticed.
@@ -3698,14 +3743,33 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
3698
3743
  // ── Warn check 8: the icon files basaltAppPlugin actually references exist ────
3699
3744
  // basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references six filenames by default
3700
3745
  // (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.
3704
- const publicDir = resolve(cwd, 'public')
3705
- if (profile === 'framework' && existsSync(publicDir)) {
3706
- const icons = readAppIconsOption(cwd)
3707
- if (icons.kind === 'none') {
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') {
3708
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.',
3772
+ )
3709
3773
  } else if (icons.kind === 'explicit') {
3710
3774
  // The app NAMED its icons, so those are the only files it owes. Checking the six defaults
3711
3775
  // here is what told 1.23.0's flagship adopter to generate five files it deliberately lacks.
@@ -3714,19 +3778,20 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
3714
3778
  )
3715
3779
  if (missing.length > 0) {
3716
3780
  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.',
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.',
3720
3784
  )
3721
3785
  } else {
3722
3786
  pass(
3723
- `public/ has all ${icons.sources.length} icon file(s) basaltAppPlugin's \`icons\` option names`,
3787
+ `${here}public/ has all ${icons.sources.length} icon file(s) basaltAppPlugin's ` +
3788
+ '`icons` option names',
3724
3789
  )
3725
3790
  }
3726
3791
  } else {
3727
3792
  const dir = icons.kind === 'default' ? icons.dir : null
3728
3793
  const iconRoot = dir === null ? publicDir : resolve(publicDir, dir.replace(/^\//, ''))
3729
- const shown = dir === null ? 'public/' : `public/${dir.replace(/^\//, '')}`
3794
+ const shown = dir === null ? `${here}public/` : `${here}public/${dir.replace(/^\//, '')}`
3730
3795
  const missingIcons = DEFAULT_APP_ICON_FILES.filter((f) => !existsSync(resolve(iconRoot, f)))
3731
3796
  if (missingIcons.length > 0) {
3732
3797
  warn(
@@ -3962,6 +4027,43 @@ const VITE_CONFIG_FILES = [
3962
4027
  'vite.config.cjs',
3963
4028
  ] as const
3964
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
+
3965
4067
  /**
3966
4068
  * What `basaltAppPlugin`'s `icons` option says this app actually has.
3967
4069
  *
@@ -4060,31 +4162,83 @@ function generatedHeader(version: string, command: string, flags: string[]): str
4060
4162
  }
4061
4163
 
4062
4164
  /**
4063
- * The generated file with its provenance line (line 2) blanked — what `--check` actually compares.
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.
4064
4192
  *
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.
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.
4069
4196
  *
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.
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.
4075
4200
  *
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.
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.
4080
4205
  */
4081
- function withoutProvenanceLine(css: string): string {
4206
+ function withoutProvenanceVersion(css: string): string {
4082
4207
  const lines = css.split('\n')
4083
- if (lines.length < 2) return css
4084
- lines[1] = ''
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}`
4085
4213
  return lines.join('\n')
4086
4214
  }
4087
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
+
4088
4242
  /**
4089
4243
  * Space- and number-normalize the argument list of a legacy color function so the emitted file
4090
4244
  * survives a normal repo's formatter.
@@ -4147,22 +4301,46 @@ function emitGeneratedCss(content: string, flags: string[], cwd: string, command
4147
4301
  )
4148
4302
  return 1
4149
4303
  }
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
- )
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}`)
4159
4315
  return 0
4160
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
+ }
4161
4338
  console.error(
4162
4339
  `✖ ${command} --check: ${shown} differs from what \`basalt-ui ${command}\` emits today ` +
4163
- `(on disk ${onDisk.split('\n').length} lines, emitted ${content.split('\n').length}) — ` +
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.',
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.',
4166
4344
  )
4167
4345
  return 1
4168
4346
  }
@@ -14,7 +14,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSy
14
14
  import { tmpdir } from 'node:os'
15
15
  import { join, resolve } from 'node:path'
16
16
 
17
- import { GENERATED_HEADER_LINE } from '../guard'
17
+ import { DEFAULT_GUARD_CONFIG, GENERATED_HEADER_LINE, checkSource } from '../guard'
18
18
  import { buildPaletteCss } from '../tokens'
19
19
  import { normalizeColorFunctions, run, tokensCss } from './index'
20
20
 
@@ -121,12 +121,17 @@ describe('tokens:css', () => {
121
121
  })
122
122
 
123
123
  /**
124
- * `--check` gates the token VALUES, never the provenance line.
124
+ * `--check` gates the token VALUES and the provenance INVOCATION — only the version token is free.
125
125
  *
126
126
  * Line 2 of the generated header carries the emitting version, and `--check` used to compare the
127
- * whole file byte for byte — so every basalt-ui release forced a mandatory no-op commit in every
128
- * consumer that commits a generated sheet: regenerate, one line moves, nothing else, ship it. That
129
- * is the rot pattern removed from DESIGN.md this cycle, on a file whose gate is byte-equality.
127
+ * whole file byte for byte, so every basalt-ui release forced a mandatory no-op commit in every
128
+ * consumer that commits a generated sheet. 1.23.1 fixed that by blanking the whole of line 2 —
129
+ * which is also the line carrying the exact invocation line 1 tells the reader to regenerate with.
130
+ * rollhook's header was rewritten from `--only core --no-legacy-aliases` to `--only all
131
+ * --with-legacy-aliases` and `--check` passed clean.
132
+ *
133
+ * Three-way contract, one test each below: a version-only change passes, a line-2 flag change
134
+ * fails, a token drift fails.
130
135
  */
131
136
  describe('tokens:css --check and the provenance line', () => {
132
137
  /** Capture stdout/stderr so the check's own sentence can be asserted. */
@@ -156,7 +161,54 @@ describe('tokens:css --check and the provenance line', () => {
156
161
  const { code, log } = check('out.css')
157
162
  expect(code).toBe(0)
158
163
  expect(log).toContain('is up to date')
159
- expect(log).toContain('provenance line still names an older basalt-ui')
164
+ // Parsed, not asserted: the old sentence claimed "an older basalt-ui" without ever reading
165
+ // the token, so `0.0.1-nonsense` got the same claim. Both versions are named now.
166
+ expect(log).toContain('names basalt-ui 0.0.1, an older release than the')
167
+ })
168
+
169
+ it('calls a NEWER on-disk version newer — the old sentence said "older" either way', () => {
170
+ const lines = emit([]).split('\n')
171
+ lines[1] = (lines[1] as string).replace(/basalt-ui [\d.]+ —/, 'basalt-ui 999.0.0 —')
172
+ writeFileSync(resolve(dir, 'out.css'), lines.join('\n'))
173
+
174
+ const { code, log } = check('out.css')
175
+ expect(code).toBe(0)
176
+ expect(log).toContain('names basalt-ui 999.0.0, a newer release than the')
177
+ expect(log).not.toContain('an older release')
178
+ })
179
+
180
+ it('calls a same-triple prerelease merely different rather than ordering it', () => {
181
+ const lines = emit([]).split('\n')
182
+ const current = /basalt-ui (\d+\.\d+\.\d+)/.exec(lines[1] as string)?.[1] as string
183
+ lines[1] = (lines[1] as string).replace(`basalt-ui ${current} —`, `basalt-ui ${current}-rc.1 —`)
184
+ writeFileSync(resolve(dir, 'out.css'), lines.join('\n'))
185
+
186
+ const { code, log } = check('out.css')
187
+ expect(code).toBe(0)
188
+ expect(log).toContain(`names basalt-ui ${current}-rc.1, a different release than the`)
189
+ })
190
+
191
+ it('FAILS when line 2 names different flags — the invocation line 1 points at is gated', () => {
192
+ const emitted = emit([])
193
+ const lines = emitted.split('\n')
194
+ lines[1] = (lines[1] as string).replace(
195
+ '`basalt-ui tokens:css --out out.css`',
196
+ '`basalt-ui tokens:css --only all --with-legacy-aliases --out out.css`',
197
+ )
198
+ writeFileSync(resolve(dir, 'out.css'), lines.join('\n'))
199
+
200
+ const { code, log } = check('out.css')
201
+ expect(code).toBe(1)
202
+ expect(log).toContain('provenance line does not match the command that produced this content')
203
+ expect(log).toContain('--only all --with-legacy-aliases')
204
+ })
205
+
206
+ it('FAILS when the provenance line is deleted outright — a blank line is not a free pass', () => {
207
+ const lines = emit([]).split('\n')
208
+ lines[1] = ''
209
+ writeFileSync(resolve(dir, 'out.css'), lines.join('\n'))
210
+
211
+ expect(check('out.css').code).toBe(1)
160
212
  })
161
213
 
162
214
  it('says nothing about provenance when the file is byte-identical', () => {
@@ -174,9 +226,24 @@ describe('tokens:css --check and the provenance line', () => {
174
226
  expect(log).toContain('differs from what')
175
227
  })
176
228
 
229
+ it('STILL fails on token drift even when the version token ALSO differs', () => {
230
+ const lines = emit([]).replace('--vx-neutral', '--vx-neutrall').split('\n')
231
+ lines[1] = (lines[1] as string).replace(/basalt-ui [\d.]+ —/, 'basalt-ui 0.0.1 —')
232
+ writeFileSync(resolve(dir, 'out.css'), lines.join('\n'))
233
+ expect(check('out.css').code).toBe(1)
234
+ })
235
+
177
236
  it('the header itself is unchanged — the `@generated` exemption needs both lines verbatim', () => {
178
237
  const lines = emit([]).split('\n')
179
238
  expect(lines[0]).toBe(GENERATED_HEADER_LINE)
180
239
  expect(lines[1]).toMatch(/^\/\* basalt-ui [\d.]+ — `basalt-ui tokens:css --out out\.css` \*\/$/)
181
240
  })
241
+
242
+ it('the emitted header is still guard-exempt — checkSource reports nothing in it', () => {
243
+ // The exemption requires line 1 verbatim AND line 2 parsing as the provenance line. `--check`
244
+ // now reads line 2 too, so the two shapes have to keep agreeing; this is the assertion that
245
+ // notices if the emitter drifts to satisfy the gate.
246
+ const emitted = emit([])
247
+ expect(checkSource(emitted, 'src/tokens.css', DEFAULT_GUARD_CONFIG)).toEqual([])
248
+ })
182
249
  })
@@ -1210,3 +1210,149 @@ describe('DESIGN.md carries no version, and sync heals the ones already written'
1210
1210
  expect(read('DESIGN.md')).toBe(owned)
1211
1211
  })
1212
1212
  })
1213
+
1214
+ // ── The ascend direction ──────────────────────────────────────────────────────
1215
+ // Round 9, basalt-ui-obsidian: from `apps/demo` — a package with no `basalt` key of its own —
1216
+ // `check-theme` invented `roots: ["src"]`, scanned 22 of the repo's 44 guarded files, printed
1217
+ // `✓ no off-palette colors`, and reported the invention back as `basalt.roots (src)`. Its
1218
+ // `--audit-allows` then reported `0 live` for a repo carrying one live waiver: a clean slate from
1219
+ // the one command whose entire job is to deny one. `doctor`, standing in the same directory,
1220
+ // already resolved the parent. The descend direction announced itself; only ascend was silent.
1221
+ describe('check-theme / doctor — ascend to the parent config instead of fabricating one', () => {
1222
+ /** Repo root owns the config and names TWO roots; neither package declares anything. */
1223
+ function twoRootRepo(): void {
1224
+ write('.git/HEAD', 'ref: refs/heads/master\n')
1225
+ write(
1226
+ 'package.json',
1227
+ JSON.stringify({
1228
+ name: 'fixture',
1229
+ workspaces: ['packages/*', 'apps/*'],
1230
+ basalt: { roots: ['packages/lib/src', 'apps/demo/src'] },
1231
+ }),
1232
+ )
1233
+ write('packages/lib/package.json', JSON.stringify({ name: 'lib' }))
1234
+ write('apps/demo/package.json', JSON.stringify({ name: 'demo' }))
1235
+ write('apps/demo/src/App.tsx', 'export const App = () => null\n')
1236
+ }
1237
+
1238
+ it('scans the WHOLE declared surface from a package that declares nothing, and says so', () => {
1239
+ twoRootRepo()
1240
+ // The violation sits in the sibling root — the half the fabricated `src` default never read.
1241
+ write('packages/lib/src/Card.tsx', `export const C = () => <div style={{ borderRadius: 8 }} />`)
1242
+
1243
+ const { code, log } = capture(() => checkTheme(join(dir, 'apps/demo')))
1244
+ expect(log).toContain('running in ../.., where it lives')
1245
+ expect(code).toBe(1)
1246
+ expect(log).toContain('raw-surface')
1247
+ })
1248
+
1249
+ it('--audit-allows reports the repo’s real waiver count, not zero', () => {
1250
+ twoRootRepo()
1251
+ write(
1252
+ 'packages/lib/src/Card.tsx',
1253
+ 'export const C = () => <div style={{ borderRadius: 8 }} /> // theme-allow: legacy widget\n',
1254
+ )
1255
+
1256
+ const { code, log } = capture(() => checkTheme(join(dir, 'apps/demo'), ['--audit-allows']))
1257
+ expect(code).toBe(0)
1258
+ expect(log).toContain('1 live, 0 dead')
1259
+ expect(log).toContain('basalt.roots (packages/lib/src, apps/demo/src)')
1260
+ })
1261
+
1262
+ it('doctor and check-theme now answer with the SAME project dir', () => {
1263
+ twoRootRepo()
1264
+ write('packages/lib/src/Card.tsx', 'export const C = () => null\n')
1265
+ write(MANIFEST_PATH, JSON.stringify({ version: 1, files: {}, basaltVersion: CLI_VERSION }))
1266
+ write('.oxlintrc.json', '{ "extends": ["./node_modules/basalt-ui/configs/oxlint.json"] }')
1267
+ installBasalt()
1268
+
1269
+ const { log } = capture(() => doctor(join(dir, 'apps/demo')))
1270
+ expect(log).toContain('reporting on ../.., where it lives')
1271
+ expect(log).toContain(`basalt-ui doctor — ${dir}`)
1272
+ })
1273
+
1274
+ it('never ascends out of the repo — no .git above cwd means no ancestor to read', () => {
1275
+ // No `.git` anywhere: the walk has no bound, so it refuses to walk at all and the built-in
1276
+ // defaults apply exactly as they did before. A standalone consumer is untouched by this.
1277
+ write('package.json', JSON.stringify({ name: 'root', basalt: { roots: ['packages/lib/src'] } }))
1278
+ write('packages/lib/package.json', JSON.stringify({ name: 'lib' }))
1279
+ write('packages/lib/src/App.tsx', 'export const App = () => null\n')
1280
+
1281
+ const { code, log } = capture(() => checkTheme(join(dir, 'packages/lib')))
1282
+ expect(log).not.toContain('where it lives')
1283
+ expect(code).toBe(0)
1284
+ })
1285
+
1286
+ it('BASALT_CWD still wins over the ascend', () => {
1287
+ twoRootRepo()
1288
+ write('packages/lib/src/Card.tsx', 'export const C = () => null\n')
1289
+ write('apps/demo/package.json', JSON.stringify({ name: 'demo', basalt: { roots: ['src'] } }))
1290
+ process.env['BASALT_CWD'] = join(dir, 'apps/demo')
1291
+ try {
1292
+ const { code, log } = capture(() => checkTheme(dir, ['--audit-allows']))
1293
+ expect(code).toBe(0)
1294
+ expect(log).toContain('basalt.roots (src)')
1295
+ } finally {
1296
+ delete process.env['BASALT_CWD']
1297
+ }
1298
+ })
1299
+ })
1300
+
1301
+ // ── The icons check, reachable wherever doctor runs ───────────────────────────
1302
+ // Round 9, rb and argo: the root run — the ONLY invocation that can exit 0 on a monorepo layout —
1303
+ // omitted the icons check entirely, with no `⊘ SKIPPED` line. It read `cwd/public` and `cwd`'s
1304
+ // vite config, and in a monorepo both live in the app package. A check that vanishes without a
1305
+ // word is indistinguishable from one that passed.
1306
+ describe('doctor — the icons check is reachable from the root, or says why not', () => {
1307
+ /** A monorepo whose app package owns the vite config, the public/ tree and nothing else. */
1308
+ function appPackageRepo(viteConfig: string): void {
1309
+ write('.git/HEAD', 'ref: refs/heads/master\n')
1310
+ write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['apps/web/src'] } }))
1311
+ write('apps/web/src/app.tsx', 'export const App = () => null\n')
1312
+ write('apps/web/vite.config.ts', viteConfig)
1313
+ write('.oxlintrc.json', '{ "extends": ["./node_modules/basalt-ui/configs/oxlint.json"] }')
1314
+ write(MANIFEST_PATH, JSON.stringify({ version: 1, files: {}, basaltVersion: CLI_VERSION }))
1315
+ installBasalt()
1316
+ }
1317
+
1318
+ it('reads the app package’s `icons` array from a ROOT-invoked run', () => {
1319
+ appPackageRepo("export default basaltAppPlugin({ icons: [{ src: '/favicon.svg' }] })\n")
1320
+ write('apps/web/public/favicon.svg', '<svg/>')
1321
+
1322
+ const { log } = capture(() => doctor(dir))
1323
+ expect(log).toContain("apps/web/public/ has all 1 icon file(s) basaltAppPlugin's `icons`")
1324
+ })
1325
+
1326
+ it('reports a missing icon from the root too — it is not just reachable, it judges', () => {
1327
+ appPackageRepo("export default basaltAppPlugin({ icons: [{ src: '/favicon.svg' }] })\n")
1328
+ write('apps/web/public/.keep', '')
1329
+
1330
+ const { log } = capture(() => doctor(dir))
1331
+ expect(log).toContain('basaltAppPlugin declares icon(s) that are not in ./apps/web/public/')
1332
+ })
1333
+
1334
+ it('SKIPS loudly when the plugin is configured but has no public/ beside it', () => {
1335
+ appPackageRepo("export default basaltAppPlugin({ icons: [{ src: '/favicon.svg' }] })\n")
1336
+
1337
+ const { code, log } = capture(() => doctor(dir))
1338
+ expect(log).toContain('⊘ SKIPPED — app icons — ./apps/web configures basaltAppPlugin')
1339
+ expect(code).toBe(1)
1340
+ })
1341
+
1342
+ it('states the absence rather than omitting the line when nothing uses the plugin', () => {
1343
+ write('.git/HEAD', 'ref: refs/heads/master\n')
1344
+ healthyFixture()
1345
+
1346
+ const { code, log } = capture(() => doctor(dir))
1347
+ expect(log).toContain('no `basaltAppPlugin(` under this project')
1348
+ expect(code).toBe(0)
1349
+ })
1350
+
1351
+ it('`icons: false` is nothing to check, from the root as from the package', () => {
1352
+ appPackageRepo('export default basaltAppPlugin({ icons: false })\n')
1353
+
1354
+ const { code, log } = capture(() => doctor(dir))
1355
+ expect(log).toContain('basaltAppPlugin is configured with no icons')
1356
+ expect(code).toBe(0)
1357
+ })
1358
+ })
@@ -24,8 +24,12 @@ export type EmptyStateProps = {
24
24
  icon?: ReactNode
25
25
  /** Head-font title (18px, weight 550, ink). */
26
26
  title: string
27
- /** Muted 15px description, capped at ~360px so it reads as a short explanation. */
28
- description: string
27
+ /**
28
+ * Muted 15px description, capped at ~360px so it reads as a short explanation. Optional: a
29
+ * compact panel ("No data yet") should not have to invent a second sentence, and five argo
30
+ * features wrapped this component solely to avoid doing so.
31
+ */
32
+ description?: string
29
33
  /** Optional call-to-action rendered below the description. */
30
34
  action?: ReactNode
31
35
  /** `'page'` (default) = generous padding for a full-page state; `'section'` = compact. */
@@ -60,7 +64,9 @@ export function EmptyState({
60
64
  >
61
65
  {title}
62
66
  </span>
63
- <span style={{ fontSize: VX.text.md, color: VX.muted, maxWidth: 360 }}>{description}</span>
67
+ {description !== undefined && (
68
+ <span style={{ fontSize: VX.text.md, color: VX.muted, maxWidth: 360 }}>{description}</span>
69
+ )}
64
70
  {action}
65
71
  </Stack>
66
72
  )