basalt-ui 1.22.0 → 1.23.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 (79) hide show
  1. package/AGENTS.md +9 -1
  2. package/CHANGELOG.md +19 -0
  3. package/MIGRATING.md +125 -12
  4. package/README.md +45 -11
  5. package/agent/rules/basalt-app.md +26 -2
  6. package/agent/rules/basalt-charts.md +40 -5
  7. package/agent/rules/basalt-tokens.md +17 -1
  8. package/agent/skills/basalt-charts/SKILL.md +23 -8
  9. package/agent/templates/DESIGN.md.tpl +3 -1
  10. package/configs/oxlint-plugin.js +49 -8
  11. package/configs/oxlint-plugin.test.ts +88 -31
  12. package/dist/charts/hooks/useBandPlot.d.ts +138 -0
  13. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
  14. package/dist/charts/hooks/useBandPlot.js +127 -0
  15. package/dist/charts/index.d.ts +4 -0
  16. package/dist/charts/index.d.ts.map +1 -1
  17. package/dist/charts/index.js +14 -0
  18. package/dist/charts/kinds/BandStrip.d.ts +113 -0
  19. package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
  20. package/dist/charts/kinds/BandStrip.js +218 -0
  21. package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
  22. package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
  23. package/dist/charts/kinds/Bars.d.ts +5 -1
  24. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  25. package/dist/charts/kinds/Bars.js +2 -0
  26. package/dist/charts/kinds/MirroredBars.d.ts +93 -0
  27. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
  28. package/dist/charts/kinds/MirroredBars.js +315 -0
  29. package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
  30. package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
  31. package/dist/charts/kinds/MultiLine.d.ts +5 -1
  32. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  33. package/dist/charts/kinds/MultiLine.js +2 -0
  34. package/dist/charts/kinds/StackedArea.d.ts +5 -1
  35. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  36. package/dist/charts/kinds/StackedArea.js +2 -0
  37. package/dist/charts/kinds/ZonedLine.d.ts +5 -1
  38. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  39. package/dist/charts/kinds/ZonedLine.js +2 -0
  40. package/dist/charts/primitives/CartesianChart.d.ts +18 -2
  41. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  42. package/dist/charts/primitives/CartesianChart.js +5 -2
  43. package/dist/charts/primitives/HatchPattern.d.ts +31 -0
  44. package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
  45. package/dist/charts/primitives/HatchPattern.js +31 -0
  46. package/dist/cli/index.d.ts +2 -0
  47. package/dist/cli/index.d.ts.map +1 -1
  48. package/dist/cli/index.js +47 -8
  49. package/dist/cli/shipped-versions.test.d.ts +2 -0
  50. package/dist/cli/shipped-versions.test.d.ts.map +1 -0
  51. package/dist/guard/index.d.ts.map +1 -1
  52. package/dist/guard/index.js +32 -11
  53. package/dist/vite.d.ts +42 -3
  54. package/dist/vite.d.ts.map +1 -1
  55. package/dist/vite.js +67 -30
  56. package/llms.txt +2 -2
  57. package/package.json +1 -1
  58. package/src/charts/hooks/useBandPlot.ts +302 -0
  59. package/src/charts/index.ts +14 -0
  60. package/src/charts/kinds/BandStrip.test.tsx +200 -0
  61. package/src/charts/kinds/BandStrip.tsx +364 -0
  62. package/src/charts/kinds/Bars.tsx +7 -1
  63. package/src/charts/kinds/DualPanel.test.tsx +10 -4
  64. package/src/charts/kinds/MirroredBars.test.tsx +193 -0
  65. package/src/charts/kinds/MirroredBars.tsx +455 -0
  66. package/src/charts/kinds/MultiLine.tsx +7 -1
  67. package/src/charts/kinds/StackedArea.tsx +7 -1
  68. package/src/charts/kinds/ZonedLine.tsx +7 -1
  69. package/src/charts/primitives/CartesianChart.test.tsx +86 -4
  70. package/src/charts/primitives/CartesianChart.tsx +28 -3
  71. package/src/charts/primitives/HatchPattern.tsx +55 -0
  72. package/src/cli/index.ts +125 -18
  73. package/src/cli/shipped-versions.test.ts +83 -0
  74. package/src/cli/toolchain-wiring.test.ts +171 -4
  75. package/src/cli/waiver-audit.test.ts +7 -3
  76. package/src/guard/check-source.test.ts +138 -42
  77. package/src/guard/index.ts +83 -17
  78. package/src/vite.test.ts +79 -0
  79. package/src/vite.ts +141 -39
@@ -151,8 +151,24 @@ export type CartesianChartProps<T> = {
151
151
  /** Passing `y2` is what makes the chart dual-axis: it draws the right axis and widens the
152
152
  * right margin by measurement. Series opt in with `axis: 'right'`. */
153
153
  y2?: AxisConfig<T>
154
- /** Exact number of x ticks. Default: as many as fit (`smartTicks`). */
154
+ /** Exact number of x ticks. Default: as many as fit (`smartTicks`). Ignored when
155
+ * `xTickValues` is set. */
155
156
  xTicks?: number
157
+ /**
158
+ * Which domain keys get a tick, chosen from the full key list and the resolved plot width.
159
+ * Takes precedence over `xTicks`; omit both for `smartTicks`.
160
+ *
161
+ * A COUNT is not always enough. `smartTicks`/`smartTicksEvery` append the final key
162
+ * unconditionally, so when the step does not land on the last index that appended tick sits a
163
+ * partial step from its neighbour — measured on a 24h window, two `DD.MM HH:MM` labels printed
164
+ * on top of each other at the right edge, at every tick count. The only fix a count can express
165
+ * is thinning the whole axis to avoid one crowded pair. A consumer whose labels are richer than
166
+ * the default `DD.MM` needs to pick the VALUES, which until now meant measuring the container
167
+ * itself with `useChartSize` to derive a count — the chart already knows its own width.
168
+ *
169
+ * `BandStrip`/`MirroredBars` take the identical prop, so the seam does not fork by kind.
170
+ */
171
+ xTickValues?: (keys: readonly string[], xMax: number) => readonly string[]
156
172
  /** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
157
173
  formatX?: (key: string) => string
158
174
  /** Value-range bands on the left scale, drawn behind the marks. */
@@ -267,6 +283,7 @@ export function CartesianChart<T>({
267
283
  y,
268
284
  y2,
269
285
  xTicks,
286
+ xTickValues,
270
287
  formatX = fmtAxisDate,
271
288
  zones,
272
289
  xZones,
@@ -305,6 +322,7 @@ export function CartesianChart<T>({
305
322
  {...(y !== undefined && { y })}
306
323
  {...(y2 !== undefined && { y2 })}
307
324
  {...(xTicks !== undefined && { xTicks })}
325
+ {...(xTickValues !== undefined && { xTickValues })}
308
326
  formatX={formatX}
309
327
  {...(zones !== undefined && { zones })}
310
328
  {...(xZones !== undefined && { xZones })}
@@ -343,6 +361,7 @@ function CartesianPlot<T>({
343
361
  y,
344
362
  y2,
345
363
  xTicks,
364
+ xTickValues,
346
365
  formatX = fmtAxisDate,
347
366
  zones,
348
367
  xZones,
@@ -459,9 +478,15 @@ function CartesianPlot<T>({
459
478
  [rightDomain, yMax, y2?.nice],
460
479
  )
461
480
 
481
+ // Resolution order: explicit VALUES win, then an explicit COUNT, then as many as fit.
462
482
  const tickValues = useMemo(
463
- () => (xTicks === undefined ? smartTicks(keys, xMax) : smartTicksEvery(keys, xTicks)),
464
- [keys, xMax, xTicks],
483
+ () =>
484
+ xTickValues !== undefined
485
+ ? [...xTickValues(keys, xMax)]
486
+ : xTicks === undefined
487
+ ? smartTicks(keys, xMax)
488
+ : smartTicksEvery(keys, xTicks),
489
+ [keys, xMax, xTicks, xTickValues],
465
490
  )
466
491
 
467
492
  const cursor = useChartCursor<T>({
@@ -0,0 +1,55 @@
1
+ import type { ReactNode } from 'react'
2
+ import { alpha } from '../../tokens'
3
+
4
+ /**
5
+ * A diagonal-hatch `<pattern>` — the one fill that reads as **absence** rather than as a weak
6
+ * measurement.
7
+ *
8
+ * A faint solid tint cannot do this job: it sits on the same perceptual axis as every other
9
+ * intensity of the same hue, so "nothing was measured here" and "everything measured here was
10
+ * fine" collapse into neighbouring steps of one ramp. Hatching is off that axis entirely and
11
+ * survives being placed beside a colour ramp of the same hue.
12
+ *
13
+ * `id` must be unique per document — `BandStrip` / `MirroredBars` namespace theirs with `chartId`.
14
+ * `opacity` is applied through `alpha()` on the stroke rather than as an `opacity` attribute, so
15
+ * the hue keeps resolving per colour scheme.
16
+ */
17
+ export function HatchPattern({
18
+ id,
19
+ color,
20
+ opacity = 0.55,
21
+ size = 6,
22
+ }: {
23
+ id: string
24
+ color: string
25
+ opacity?: number
26
+ size?: number
27
+ }): ReactNode {
28
+ return (
29
+ <pattern
30
+ id={id}
31
+ width={size}
32
+ height={size}
33
+ patternUnits="userSpaceOnUse"
34
+ patternTransform="rotate(45)"
35
+ >
36
+ <line x1={0} y1={0} x2={0} y2={size} stroke={alpha(color, opacity)} strokeWidth={size / 3} />
37
+ </pattern>
38
+ )
39
+ }
40
+
41
+ /** `fill` value referencing a {@link HatchPattern} by id. */
42
+ export function hatchFill(id: string): string {
43
+ return `url(#${id})`
44
+ }
45
+
46
+ /**
47
+ * The pattern's own repeat, shrunk to fit the band it textures.
48
+ *
49
+ * Left at a fixed 6px a sub-pixel band draws less than one diagonal rule, so a fill/hatch split
50
+ * inside one band antialiases into a single smudge no reader can tell from a fully-measured band.
51
+ * Floored at 2 — below that the stroke has nothing to render on.
52
+ */
53
+ export function hatchSizeFor(bandWidth: number): number {
54
+ return Math.max(2, Math.min(6, Math.round(bandWidth)))
55
+ }
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(
@@ -1743,6 +1783,34 @@ function migrateLegacyOxfmt(cwd: string, pkgRoot: string, manifest: Manifest): v
1743
1783
  if (shipped !== null && onDisk === shipped) unlinkSync(legacyAbs)
1744
1784
  }
1745
1785
 
1786
+ /** The frozen `> Managed by basalt-ui (1.9.0).` opener every pre-fix DESIGN.md seed carries. */
1787
+ const DESIGN_VERSION_LINE = /^(> Managed by basalt-ui) \(\d+\.\d+\.\d+\)\./m
1788
+
1789
+ /**
1790
+ * Strip the frozen version out of an already-seeded `DESIGN.md` opener.
1791
+ *
1792
+ * `DESIGN.md` is a `seed`: written once, then consumer-owned and never reconciled. It opened with
1793
+ * `> Managed by basalt-ui ({{BASALT_VERSION}})`, which is accurate for exactly as long as it takes
1794
+ * to run one upgrade — measured across the seven consumers the same line read 1.0.0, 1.9.0, 1.21.0
1795
+ * and 1.22.0 under an identical 1.22.0 install, and three rounds of reports treated that as four
1796
+ * separate doc bugs. It was one: a version number in a file nothing ever rewrites.
1797
+ *
1798
+ * Dropping it from the template fixes new scaffolds only — every existing consumer would carry its
1799
+ * stale number forever, since sync does not touch a seed. So sync deletes the parenthetical in
1800
+ * place, and only that: not a re-seed, not a three-way merge. Everything else in the file is left
1801
+ * exactly as the consumer wrote it, and a DESIGN.md whose opener was already rewritten by hand
1802
+ * does not match at all.
1803
+ */
1804
+ function migrateDesignVersionLine(cwd: string): void {
1805
+ const dest = resolve(cwd, 'DESIGN.md')
1806
+ const onDisk = readIfExists(dest)
1807
+ if (onDisk === null || !DESIGN_VERSION_LINE.test(onDisk)) return
1808
+ writeFileEnsuringDir(
1809
+ dest,
1810
+ onDisk.replace(DESIGN_VERSION_LINE, '$1 — run `basalt-ui doctor` for the version.'),
1811
+ )
1812
+ }
1813
+
1746
1814
  function writeFileEnsuringDir(abs: string, content: string): void {
1747
1815
  mkdirSync(dirname(abs), { recursive: true })
1748
1816
  writeFileSync(abs, content)
@@ -2519,7 +2587,12 @@ function readOxlintPresetPlugins(pkgRoot: string): string[] {
2519
2587
  }
2520
2588
  }
2521
2589
 
2522
- type SyncOptions = { force?: boolean; check?: boolean }
2590
+ type SyncOptions = {
2591
+ force?: boolean
2592
+ check?: boolean
2593
+ /** The raw flag list, read for `--tokens-only` / `--framework` exactly as check-theme reads it. */
2594
+ flags?: readonly string[]
2595
+ }
2523
2596
 
2524
2597
  /**
2525
2598
  * The nearest ANCESTOR of `dir` (exclusive) carrying a basalt manifest, or null.
@@ -2554,11 +2627,16 @@ function findManifestAbove(dir: string): string | null {
2554
2627
  * `sync` does not get to make silently.
2555
2628
  */
2556
2629
  export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd()): number {
2630
+ const syncFlags = opts.flags ?? []
2631
+ if (conflictingProfileFlags(syncFlags)) {
2632
+ console.error('basalt-ui sync: --tokens-only and --framework are alternatives — pass one.')
2633
+ return 1
2634
+ }
2557
2635
  const project = resolveProjectDir(invocationCwd)
2558
2636
  if (project.ambiguous !== null) {
2559
2637
  console.error(
2560
2638
  `✖ 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(', ')}) — ` +
2639
+ `packages below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
2562
2640
  'run it from one of them, or set BASALT_CWD to pick.',
2563
2641
  )
2564
2642
  return 1
@@ -2571,6 +2649,24 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
2571
2649
  )
2572
2650
  }
2573
2651
 
2652
+ // Profile before the refusal, because in a tokens-only consumer the refusal's own advice is
2653
+ // wrong: it names `basalt-ui init`, and `doctor` in the same directory at the same version says
2654
+ // in so many words that init is NOT the fix there — it places a Mantine doctrine the consumer
2655
+ // has no use for. Mirrors doctor's manifest row verbatim, and exits 0 so `sync --check` is
2656
+ // wirable into a tokens-only repo's CI (the one drift gate every other consumer runs).
2657
+ //
2658
+ // DECLARED, never inferred — the same asymmetry check-theme applies, for the same reason: the
2659
+ // answer SILENCES the whole reconciliation, and inferring it from a missing @mantine/core would
2660
+ // turn a framework consumer that keeps Mantine in a sibling package into a silent no-op.
2661
+ if (declaredProfile(readBasaltConfig(cwd), syncFlags) === 'tokens-only') {
2662
+ console.log(
2663
+ `basalt-ui sync: ${MANIFEST_PATH}: n/a — a tokens-only consumer has no scaffold to ` +
2664
+ 'reconcile. The token layer is refreshed by re-running `basalt-ui tokens:css`, and ' +
2665
+ '`tokens:css --check` is its drift gate. Pass --framework to force the full profile.',
2666
+ )
2667
+ return 0
2668
+ }
2669
+
2574
2670
  // The refusal runs BEFORE reconcileRoots, which WRITES `basalt.roots` into package.json — that
2575
2671
  // key is half the damage: a second `basalt` block means check-theme scans a different tree
2576
2672
  // depending on which directory it was invoked from.
@@ -2595,7 +2691,10 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
2595
2691
  const rootsState = reconcileRoots(cwd, { write: opts.check !== true })
2596
2692
  const ctx = renderContext(cwd)
2597
2693
  const manifest = readManifest(cwd)
2598
- if (!opts.check) migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
2694
+ if (!opts.check) {
2695
+ migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
2696
+ migrateDesignVersionLine(cwd)
2697
+ }
2599
2698
  const peers = resolvePeerFlags(cwd, {})
2600
2699
  const placement = resolvePlacement(cwd)
2601
2700
  const files = managedFiles(peers, placement)
@@ -3228,8 +3327,8 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
3228
3327
  if (project.ambiguous !== null) {
3229
3328
  console.error(
3230
3329
  `\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(', ')}) — ` +
3330
+ ` ✖ no basalt config at ${invocationCwd}, and ${project.ambiguous.length} packages ` +
3331
+ `below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
3233
3332
  'run doctor from one of them, or set BASALT_CWD to pick.\n\n' +
3234
3333
  '1 hard failure(s), 0 warning(s).\n',
3235
3334
  )
@@ -4120,7 +4219,8 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
4120
4219
 
4121
4220
  /** The one usage string — printed by `basalt help` / `--help` / `-h` AND the unknown-command fallback. */
4122
4221
  const USAGE =
4123
- 'Usage: basalt-ui <init [--with-router] [--with-query] [--merge-lint] | sync [--force] [--check] |\n' +
4222
+ 'Usage: basalt-ui <init [--with-router] [--with-query] [--merge-lint] |\n' +
4223
+ ' sync [--force] [--check] [--tokens-only|--framework] |\n' +
4124
4224
  ' check-theme [--audit-allows] | check-coverage | info [--json] |\n' +
4125
4225
  ' doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\n' +
4126
4226
  'check-theme [--tokens-only|--framework] [--audit-allows]\n' +
@@ -4143,9 +4243,13 @@ const USAGE =
4143
4243
  'doctor [--tokens-only|--framework]\n' +
4144
4244
  ' Auto-detects a tokens-only consumer (no manifest + no @mantine/core) and checks only what\n' +
4145
4245
  ' 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' +
4246
+ 'sync [--force] [--check] [--tokens-only|--framework]\n' +
4247
+ ' Refreshes an EXISTING install; it never creates one (that is init). A tokens-only consumer\n' +
4248
+ ' has no scaffold to reconcile, so sync reports n/a and exits 0 there — as doctor does.\n\n' +
4249
+ 'check-theme / doctor / sync all resolve their project the same way: they honour BASALT_CWD, and\n' +
4250
+ 'relocate to the single package below the cwd carrying a basalt config when invoked from a repo\n' +
4251
+ 'root that has none — several carrying one is ambiguous and exits 1 in ALL THREE.\n' +
4252
+ '--tokens-only and --framework are alternatives; passing both is an error.\n\n' +
4149
4253
  'Every subcommand accepts --help / -h to print this message and exit without running.'
4150
4254
 
4151
4255
  /**
@@ -4179,7 +4283,10 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
4179
4283
  mergeLint: flags.includes('--merge-lint'),
4180
4284
  })
4181
4285
  case 'sync':
4182
- return sync({ force: flags.includes('--force'), check: flags.includes('--check') }, cwd)
4286
+ return sync(
4287
+ { force: flags.includes('--force'), check: flags.includes('--check'), flags },
4288
+ cwd,
4289
+ )
4183
4290
  case 'check-theme':
4184
4291
  return checkTheme(cwd, flags)
4185
4292
  case 'check-coverage':
@@ -0,0 +1,83 @@
1
+ /**
2
+ * No shipped artifact may name a basalt version npm never served.
3
+ *
4
+ * Three rounds running, the newest `MIGRATING.md` heading named a phantom — `1.20.1` (shipped as
5
+ * 1.21.0), then `1.21.1` (shipped as 1.22.0) — because the docs pass runs BEFORE semantic-release
6
+ * computes the number, so whatever is written is a guess. That would be a docs problem alone if it
7
+ * stayed in docs. It does not: `sync` copies `agent/rules/**` and `agent/skills/**` verbatim into
8
+ * every consumer, so "Since 1.21.1" is currently shipped, as fact, into seven repos.
9
+ *
10
+ * This is the gate that ends it, and it is mechanical rather than editorial: every basalt-shaped
11
+ * version literal in a file a consumer receives must appear as a release heading in CHANGELOG.md —
12
+ * the one file `semantic-release` writes AFTER the number exists. A guessed version fails here
13
+ * before it can ship.
14
+ *
15
+ * Deliberately NOT a ban on version literals: `agent/rules/**` is release-history prose and
16
+ * "widened at 1.21.0, promoted at 1.22.0" is exactly the sentence a consumer needs. What it may
17
+ * not do is name a release that does not exist.
18
+ *
19
+ * The complementary half — no version stamped into a file nothing ever rewrites — lives in
20
+ * `toolchain-wiring.test.ts` ("DESIGN.md carries no version").
21
+ *
22
+ * Run: bun test packages/basalt-ui/src/cli/shipped-versions.test.ts
23
+ */
24
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
25
+ import { join, relative, resolve } from 'node:path'
26
+ import { fileURLToPath } from 'node:url'
27
+ import { describe, expect, it } from 'bun:test'
28
+
29
+ const PKG_ROOT = fileURLToPath(new URL('../../', import.meta.url))
30
+
31
+ /** Every version CHANGELOG.md records, plus the one package.json currently declares. */
32
+ const RELEASED = new Set<string>([
33
+ ...(
34
+ readFileSync(resolve(PKG_ROOT, 'CHANGELOG.md'), 'utf8').match(/^#+\s*\[?(\d+\.\d+\.\d+)/gm) ??
35
+ []
36
+ ).map((h) => h.replace(/^#+\s*\[?/, '')),
37
+ (JSON.parse(readFileSync(resolve(PKG_ROOT, 'package.json'), 'utf8')) as { version: string })
38
+ .version,
39
+ ])
40
+
41
+ /**
42
+ * basalt's own version line is 0.x / 1.x, so a literal with any other major belongs to a peer
43
+ * (visx 4.0.0, motion 12.42.0, the fontsource 5.2.x trio) and is none of this gate's business. A
44
+ * `^`/`~`/`@`-prefixed literal is a dependency RANGE for the same reason — `vite-plugin-pwa
45
+ * (^1.3.0)` shares a major with basalt and is still not a claim about basalt.
46
+ */
47
+ const BASALT_VERSION_LITERAL = /(^|[^-~^@\w.])([01]\.\d+\.\d+)(?![\w.])/g
48
+
49
+ function versionsIn(content: string): string[] {
50
+ return [...content.matchAll(BASALT_VERSION_LITERAL)].map((m) => m[2] as string)
51
+ }
52
+
53
+ /** Every file under `dir`, recursively. */
54
+ function filesUnder(dir: string): string[] {
55
+ return readdirSync(dir).flatMap((name) => {
56
+ const abs = join(dir, name)
57
+ return statSync(abs).isDirectory() ? filesUnder(abs) : [abs]
58
+ })
59
+ }
60
+
61
+ // Everything `init`/`sync` place in a consumer repo, plus the presets they `extends` into.
62
+ const SHIPPED = [
63
+ ...filesUnder(resolve(PKG_ROOT, 'agent')),
64
+ resolve(PKG_ROOT, 'configs/check.yml'),
65
+ resolve(PKG_ROOT, 'configs/lefthook.yml'),
66
+ ]
67
+
68
+ describe('shipped agent assets name only versions that exist', () => {
69
+ it.each(SHIPPED.map((abs) => [relative(PKG_ROOT, abs), abs] as const))(
70
+ '%s',
71
+ (_rel, abs: string) => {
72
+ const unreleased = [...new Set(versionsIn(readFileSync(abs, 'utf8')))].filter(
73
+ (v) => !RELEASED.has(v),
74
+ )
75
+ expect(unreleased).toEqual([])
76
+ },
77
+ )
78
+
79
+ it('reads a real changelog, so an empty RELEASED set can never make this vacuous', () => {
80
+ expect(RELEASED.size).toBeGreaterThan(20)
81
+ expect(RELEASED.has('1.21.1')).toBe(false)
82
+ })
83
+ })
@@ -344,7 +344,7 @@ describe('check-theme — finds the config a root-invoked hook cannot see', () =
344
344
  it('reports ambiguity rather than guessing when two packages carry a config', () => {
345
345
  ambiguousWorkspace()
346
346
  const { code, log } = capture(() => checkTheme(dir))
347
- expect(log).toContain('2 workspace packages carry one')
347
+ expect(log).toContain('2 packages below it carry one')
348
348
  expect(log).toContain('BASALT_CWD')
349
349
  expect(code).toBe(1)
350
350
  })
@@ -363,7 +363,7 @@ describe('doctor — an ambiguous project is terminal, exactly as it is for chec
363
363
  it('short-circuits instead of running every check against the wrong root', () => {
364
364
  ambiguousWorkspace()
365
365
  const { code, log } = capture(() => doctor(dir))
366
- expect(log).toContain('2 workspace packages carry one')
366
+ expect(log).toContain('2 packages below it carry one')
367
367
  expect(log).toContain('BASALT_CWD')
368
368
  expect(code).toBe(1)
369
369
  // The bug this pins shut: falling back to the invocation cwd ran the remaining checks against
@@ -381,7 +381,7 @@ describe('doctor — an ambiguous project is terminal, exactly as it is for chec
381
381
  it('still runs normally once BASALT_CWD picks a package', () => {
382
382
  ambiguousWorkspace()
383
383
  const { code, log } = capture(() => doctor(join(dir, 'apps/a')))
384
- expect(log).not.toContain('workspace packages carry one')
384
+ expect(log).not.toContain('packages below it carry one')
385
385
  expect(code).toBe(1) // no install / no manifest there — but the checks RAN
386
386
  expect(log).toContain('guard-scan')
387
387
  })
@@ -945,7 +945,7 @@ describe('sync — a scaffold is init’s decision, never a drift refresh’s',
945
945
  ambiguousWorkspace()
946
946
  const { code, log } = capture(() => sync({}, dir))
947
947
  expect(code).toBe(1)
948
- expect(log).toContain('2 workspace packages carry one')
948
+ expect(log).toContain('2 packages below it carry one')
949
949
  expect(log).toContain('BASALT_CWD')
950
950
  })
951
951
 
@@ -1043,3 +1043,170 @@ describe('doctor — exit status per outcome, not per printed string', () => {
1043
1043
  expect(capture(() => doctor(dir, ['--tokens-only', '--framework'])).code).toBe(1)
1044
1044
  })
1045
1045
  })
1046
+
1047
+ // ── the project resolver, for a repo that declares no workspaces ───────────────────────────────
1048
+ //
1049
+ // linewatch keeps its whole basalt consumer in `web/` and declares no `workspaces` field anywhere.
1050
+ // Measured at its repo root, all three project-scoped commands got that wrong in a different way:
1051
+ // `check-theme` printed `✓ no off-palette colors` having scanned ZERO files, `doctor` inferred
1052
+ // `tokens-only` for a full Mantine consumer, and `sync` refused while naming `basalt-ui init` as
1053
+ // the remedy — the one command that WOULD have written a competing install at the root. The
1054
+ // workspace-declaration path already handled image-share's `apps/admin`; nothing handled this.
1055
+
1056
+ /** linewatch's shape: no `workspaces` field, the whole consumer one directory down. */
1057
+ function undeclaredChild(child = 'web'): void {
1058
+ write('package.json', JSON.stringify({ name: 'root' }))
1059
+ write(`${child}/package.json`, JSON.stringify({ name: child, basalt: { roots: ['src'] } }))
1060
+ write(`${child}/src/app.tsx`, "export const c = '#ff0000'\n")
1061
+ write(join(child, MANIFEST_PATH), JSON.stringify({ version: 1, files: {} }))
1062
+ }
1063
+
1064
+ describe('resolveProjectDir — a repo that declares no workspaces still has a layout', () => {
1065
+ it('sync relocates into the undeclared child instead of refusing with init as the advice', () => {
1066
+ undeclaredChild()
1067
+ const { log } = capture(() => sync({ check: true }, dir))
1068
+ expect(log).toContain('running in ./web, where it lives')
1069
+ expect(log).not.toContain('basalt-ui init')
1070
+ })
1071
+
1072
+ it('sync writes NOTHING at the root it was invoked from', () => {
1073
+ undeclaredChild()
1074
+ capture(() => sync({}, dir))
1075
+ expect(readdirSync(dir).toSorted()).toEqual(['package.json', 'web'])
1076
+ expect(readPkg()['basalt']).toBeUndefined()
1077
+ })
1078
+
1079
+ it('check-theme scans the child rather than reporting green over zero files', () => {
1080
+ undeclaredChild()
1081
+ const { code, log } = capture(() => checkTheme(dir, []))
1082
+ expect(log).toContain('running in ./web, where it lives')
1083
+ expect(log).not.toContain('no off-palette colors')
1084
+ expect(code).toBe(1)
1085
+ })
1086
+
1087
+ it('doctor reports on the child, so it cannot infer tokens-only from the empty root', () => {
1088
+ undeclaredChild()
1089
+ const { log } = capture(() => doctor(dir, []))
1090
+ expect(log).toContain(join(dir, 'web'))
1091
+ expect(log).not.toContain('profile: tokens-only')
1092
+ })
1093
+
1094
+ it('two undeclared children are ambiguous, named, and never guessed between', () => {
1095
+ undeclaredChild('web')
1096
+ undeclaredChild('admin')
1097
+ const { code, log } = capture(() => sync({}, dir))
1098
+ expect(code).toBe(1)
1099
+ expect(log).toContain('2 packages below it carry one')
1100
+ expect(log).toContain('./admin')
1101
+ expect(log).toContain('./web')
1102
+ expect(readdirSync(dir)).not.toContain('DESIGN.md')
1103
+ })
1104
+
1105
+ it('a declared workspaces field still wins — the layout scan is a fallback, not a union', () => {
1106
+ // `stray/` carries a basalt key but is not a workspace package. Treating both as candidates
1107
+ // would turn a working single-candidate repo into an ambiguity error on upgrade.
1108
+ write('package.json', JSON.stringify({ name: 'root', workspaces: ['apps/*'] }))
1109
+ write('apps/web/package.json', JSON.stringify({ name: 'web', basalt: { roots: ['src'] } }))
1110
+ write('apps/web/src/app.tsx', 'export const App = () => null\n')
1111
+ write(join('apps/web', MANIFEST_PATH), JSON.stringify({ version: 1, files: {} }))
1112
+ write('stray/package.json', JSON.stringify({ name: 'stray', basalt: { roots: ['src'] } }))
1113
+ const { log } = capture(() => sync({ check: true }, dir))
1114
+ expect(log).toContain('running in ./apps/web, where it lives')
1115
+ })
1116
+ })
1117
+
1118
+ // ── sync honours the profile doctor already reads ──────────────────────────────────────────────
1119
+ //
1120
+ // rollhook is tokens-only in both apps and declares it. `sync` told it to run `basalt-ui init` —
1121
+ // the exact advice `doctor`, in the same directory at the same version, exists to prevent — and
1122
+ // exited 1, which also put `sync --check` out of reach of the one CI drift gate every other
1123
+ // consumer runs.
1124
+
1125
+ describe('sync — tokens-only is a pass, not a refusal', () => {
1126
+ function tokensOnly(): void {
1127
+ write(
1128
+ 'package.json',
1129
+ JSON.stringify({ name: 'marketing', basalt: { roots: ['src'], profile: 'tokens-only' } }),
1130
+ )
1131
+ write('src/app.ts', 'export const c = 1\n')
1132
+ }
1133
+
1134
+ it('reports n/a and exits 0, mirroring doctor’s manifest row', () => {
1135
+ tokensOnly()
1136
+ const { code, log } = capture(() => sync({}, dir))
1137
+ expect(code).toBe(0)
1138
+ expect(log).toContain('n/a — a tokens-only consumer has no scaffold to reconcile')
1139
+ expect(log).not.toContain('basalt-ui init')
1140
+ })
1141
+
1142
+ it('is wirable into CI: --check exits 0 instead of failing on a scaffold it must not have', () => {
1143
+ tokensOnly()
1144
+ expect(capture(() => sync({ check: true, flags: [] }, dir)).code).toBe(0)
1145
+ })
1146
+
1147
+ it('writes nothing — no manifest, no DESIGN.md, no basalt.roots backfill', () => {
1148
+ tokensOnly()
1149
+ capture(() => sync({}, dir))
1150
+ expect(readdirSync(dir).toSorted()).toEqual(['package.json', 'src'])
1151
+ })
1152
+
1153
+ it('--framework forces the full profile back on', () => {
1154
+ tokensOnly()
1155
+ const { code, log } = capture(() => sync({ flags: ['--framework'] }, dir))
1156
+ expect(code).toBe(1)
1157
+ expect(log).toContain('refusing to scaffold')
1158
+ })
1159
+
1160
+ it('contradictory profile flags exit 1, exactly as they do for check-theme and doctor', () => {
1161
+ tokensOnly()
1162
+ expect(capture(() => sync({ flags: ['--tokens-only', '--framework'] }, dir)).code).toBe(1)
1163
+ })
1164
+ })
1165
+
1166
+ // ── no shipped artifact carries a version number that goes stale ───────────────────────────────
1167
+ //
1168
+ // `DESIGN.md` is a seed: written once at init, then consumer-owned and never reconciled. Its
1169
+ // opener stamped `{{BASALT_VERSION}}`, so across the seven consumers the same line read 1.0.0,
1170
+ // 1.9.0, 1.21.0 and 1.22.0 under an identical 1.22.0 install — reported three rounds running as
1171
+ // four separate doc bugs. It was one bug: a version number in a file nothing ever rewrites.
1172
+
1173
+ describe('DESIGN.md carries no version, and sync heals the ones already written', () => {
1174
+ function seeded(): void {
1175
+ write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['src'] } }))
1176
+ write('src/app.tsx', 'export const App = () => null\n')
1177
+ capture(() => init(dir))
1178
+ }
1179
+
1180
+ it('a fresh scaffold stamps no version at all', () => {
1181
+ seeded()
1182
+ expect(read('DESIGN.md')).not.toMatch(/Managed by basalt-ui \(\d+\.\d+\.\d+\)/)
1183
+ expect(read('DESIGN.md')).toContain('run `basalt-ui doctor` for the version')
1184
+ })
1185
+
1186
+ it('sync rewrites a stale opener in place and leaves the rest of the file alone', () => {
1187
+ seeded()
1188
+ const consumerOwned = '\n## Series\n\nOurs: hrv, rhr.\n'
1189
+ write('DESIGN.md', `# app — Design\n\n> Managed by basalt-ui (1.9.0). Thin.\n${consumerOwned}`)
1190
+ capture(() => sync({}, dir))
1191
+ expect(read('DESIGN.md')).toContain(
1192
+ '> Managed by basalt-ui — run `basalt-ui doctor` for the version. Thin.',
1193
+ )
1194
+ expect(read('DESIGN.md')).toContain(consumerOwned)
1195
+ })
1196
+
1197
+ it('--check writes nothing, including the heal', () => {
1198
+ seeded()
1199
+ const stale = '# app — Design\n\n> Managed by basalt-ui (1.9.0). Thin.\n'
1200
+ write('DESIGN.md', stale)
1201
+ capture(() => sync({ check: true }, dir))
1202
+ expect(read('DESIGN.md')).toBe(stale)
1203
+ })
1204
+
1205
+ it('leaves an opener the consumer already rewrote by hand untouched', () => {
1206
+ seeded()
1207
+ const owned = '# app — Design\n\n> Our own words entirely.\n'
1208
+ write('DESIGN.md', owned)
1209
+ capture(() => sync({}, dir))
1210
+ expect(read('DESIGN.md')).toBe(owned)
1211
+ })
1212
+ })