basalt-ui 1.22.0 → 1.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/AGENTS.md +29 -12
  2. package/CHANGELOG.md +28 -0
  3. package/MIGRATING.md +207 -12
  4. package/README.md +74 -21
  5. package/agent/rules/basalt-app.md +33 -3
  6. package/agent/rules/basalt-charts.md +78 -7
  7. package/agent/rules/basalt-content.md +3 -3
  8. package/agent/rules/basalt-mantine.md +4 -4
  9. package/agent/rules/basalt-tokens.md +35 -6
  10. package/agent/skills/basalt-app/SKILL.md +25 -12
  11. package/agent/skills/basalt-charts/SKILL.md +27 -10
  12. package/agent/templates/CLAUDE-block.md.tpl +3 -2
  13. package/agent/templates/DESIGN.md.tpl +3 -1
  14. package/configs/oxlint-plugin.js +49 -8
  15. package/configs/oxlint-plugin.test.ts +88 -31
  16. package/dist/charts/hooks/useBandPlot.d.ts +138 -0
  17. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
  18. package/dist/charts/hooks/useBandPlot.js +127 -0
  19. package/dist/charts/index.d.ts +4 -0
  20. package/dist/charts/index.d.ts.map +1 -1
  21. package/dist/charts/index.js +14 -0
  22. package/dist/charts/kinds/BandStrip.d.ts +127 -0
  23. package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
  24. package/dist/charts/kinds/BandStrip.js +271 -0
  25. package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
  26. package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
  27. package/dist/charts/kinds/Bars.d.ts +5 -1
  28. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  29. package/dist/charts/kinds/Bars.js +2 -0
  30. package/dist/charts/kinds/MirroredBars.d.ts +95 -0
  31. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
  32. package/dist/charts/kinds/MirroredBars.js +328 -0
  33. package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
  34. package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
  35. package/dist/charts/kinds/MultiLine.d.ts +5 -1
  36. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  37. package/dist/charts/kinds/MultiLine.js +2 -0
  38. package/dist/charts/kinds/StackedArea.d.ts +5 -1
  39. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  40. package/dist/charts/kinds/StackedArea.js +2 -0
  41. package/dist/charts/kinds/ZonedLine.d.ts +5 -1
  42. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  43. package/dist/charts/kinds/ZonedLine.js +2 -0
  44. package/dist/charts/primitives/CartesianChart.d.ts +18 -2
  45. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  46. package/dist/charts/primitives/CartesianChart.js +5 -2
  47. package/dist/charts/primitives/HatchPattern.d.ts +31 -0
  48. package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
  49. package/dist/charts/primitives/HatchPattern.js +31 -0
  50. package/dist/cli/index.d.ts +2 -0
  51. package/dist/cli/index.d.ts.map +1 -1
  52. package/dist/cli/index.js +228 -31
  53. package/dist/cli/shipped-versions.test.d.ts +2 -0
  54. package/dist/cli/shipped-versions.test.d.ts.map +1 -0
  55. package/dist/guard/index.d.ts.map +1 -1
  56. package/dist/guard/index.js +60 -13
  57. package/dist/vite.d.ts +42 -3
  58. package/dist/vite.d.ts.map +1 -1
  59. package/dist/vite.js +67 -30
  60. package/llms.txt +2 -2
  61. package/package.json +1 -1
  62. package/src/charts/hooks/useBandPlot.ts +302 -0
  63. package/src/charts/index.ts +14 -0
  64. package/src/charts/kinds/BandStrip.test.tsx +282 -0
  65. package/src/charts/kinds/BandStrip.tsx +457 -0
  66. package/src/charts/kinds/Bars.tsx +7 -1
  67. package/src/charts/kinds/DualPanel.test.tsx +10 -4
  68. package/src/charts/kinds/MirroredBars.test.tsx +232 -0
  69. package/src/charts/kinds/MirroredBars.tsx +491 -0
  70. package/src/charts/kinds/MultiLine.tsx +7 -1
  71. package/src/charts/kinds/StackedArea.tsx +7 -1
  72. package/src/charts/kinds/ZonedLine.tsx +7 -1
  73. package/src/charts/primitives/CartesianChart.test.tsx +86 -4
  74. package/src/charts/primitives/CartesianChart.tsx +28 -3
  75. package/src/charts/primitives/HatchPattern.tsx +55 -0
  76. package/src/cli/help.test.ts +74 -1
  77. package/src/cli/index.ts +436 -52
  78. package/src/cli/shipped-versions.test.ts +83 -0
  79. package/src/cli/tokens-css.test.ts +62 -0
  80. package/src/cli/toolchain-wiring.test.ts +171 -4
  81. package/src/cli/waiver-audit.test.ts +7 -3
  82. package/src/guard/check-source.test.ts +211 -42
  83. package/src/guard/index.ts +149 -23
  84. package/src/vite.test.ts +79 -0
  85. package/src/vite.ts +141 -39
package/AGENTS.md CHANGED
@@ -62,21 +62,38 @@ duration/spring/ease literal in a `transition={{...}}` prop (enforced by `basalt
62
62
 
63
63
  ## Key commands
64
64
 
65
+ `init` and `tokens:css`/`fonts:css` are the only commands with no install to resolve, so `bunx` is
66
+ correct for them and wrong for everything else — see the note below.
67
+
65
68
  ```bash
66
- bunx basalt-ui init # FIRST STEP: scaffold .claude/rules/ + .claude/skills/, managed CLAUDE.md block, DESIGN.md seed, toolchain seeds
67
- bunx basalt-ui check-theme # fail on off-palette colors in consumer source
68
- bunx basalt-ui sync # reconcile managed files (.claude/rules/, .claude/skills/, CLAUDE.md block)
69
- bunx basalt-ui sync --check # CI freshness gate — exits non-zero if any managed file drifted
70
- bunx basalt-ui doctor # integration health — SKIPPED is a third outcome and exits non-zero
71
- bunx basalt-ui tokens:css # emit the --vx-* stylesheet (no React, no Mantine, no bundler)
72
- bunx basalt-ui fonts:css # emit the shipped --basalt-font-* stacks
73
- bunx basalt-ui info # print published surface map
74
- bunx basalt-ui info --json # stable JSON surface map
75
- bunx basalt-ui guard-hook # PreToolUse theme-guard adapter: reads a Write/Edit payload on stdin, denies off-palette writes
69
+ bunx basalt-ui init # FIRST STEP: scaffold .claude/rules/ + .claude/skills/, managed CLAUDE.md block, DESIGN.md seed, toolchain seeds
70
+ basalt-ui --version # one bare line, exit 0 — which CLI is actually running (also -v / version)
71
+ basalt-ui check-theme # fail on off-palette colors in consumer source
72
+ basalt-ui sync # reconcile managed files (.claude/rules/, .claude/skills/, CLAUDE.md block)
73
+ basalt-ui sync --check # CI freshness gate — exits non-zero if any managed file drifted
74
+ basalt-ui doctor # integration health — SKIPPED is a third outcome and exits non-zero
75
+ bunx basalt-ui tokens:css # emit the --vx-* stylesheet (no React, no Mantine, no bundler)
76
+ bunx basalt-ui fonts:css # emit the shipped --basalt-font-* stacks
77
+ basalt-ui info --json # stable JSON surface map
78
+ basalt-ui guard-hook # PreToolUse theme-guard adapter: reads a Write/Edit payload on stdin, denies off-palette writes
76
79
  ```
77
80
 
81
+ Every subcommand rejects a flag it does not accept, exiting 1 and naming it; an unknown command says
82
+ so above the usage block. Neither is ever ignored — `doctor --json` used to run doctor and exit 0.
83
+
84
+ `check-theme`, `doctor` and `sync` resolve their project identically: `BASALT_CWD` wins, then the
85
+ cwd, then the workspace packages the root declares, then a two-level layout scan (which is what a
86
+ repo with no `workspaces` field and the install one level down needs). Two candidates is ambiguous
87
+ and reported, never guessed. `sync` on a `tokens-only` consumer prints `n/a` and exits 0.
88
+
89
+ **Run the local bin, not `bunx`.** `bunx` does not re-resolve a cached package, so it can execute
90
+ the version you just upgraded away from — one consumer filed a P0 against a 1.20.0 cache while
91
+ believing it was on 1.22.0. `init` and `sync` render every seeded script, CI step and hook with the
92
+ resolved local bin for this reason (`BASALT_BIN` overrides it). `basalt-ui --version` is the
93
+ one-second check.
94
+
78
95
  Register `guard-hook` in `.claude/settings.json` under `hooks.PreToolUse` with matcher
79
- `Write|Edit|MultiEdit` and command `bunx basalt-ui guard-hook` so the agent can't write off-palette
96
+ `Write|Edit|MultiEdit` and command `./node_modules/.bin/basalt-ui guard-hook` (what `init` seeds) so the agent can't write off-palette
80
97
  colors without a `theme-allow` comment.
81
98
 
82
99
  **The escape hatch is `theme-allow <rule-id> — <reason>`**, scoped to that one kind and to that one
@@ -88,7 +105,7 @@ over the declaration it terminates. Whole-file scope is `theme-allow-file <id>
88
105
  reports `theme-allow-unscoped`. A word in the id slot that names no rule waives nothing.
89
106
  `.json`/`.webmanifest` use a `"basalt:theme-allow[-file]"` member. Audit them all with
90
107
  `basalt-ui check-theme --audit-allows`, which exits 1 on a waiver that suppresses nothing. It judges
91
- plugin-rule waivers too (since 1.21.1), by re-running oxlint — so it needs oxlint reachable;
108
+ plugin-rule waivers too (since 1.22.0), by re-running oxlint — so it needs oxlint reachable;
92
109
  otherwise the verdict is "cannot judge", never "dead".
93
110
 
94
111
  > **Framework-internal only** — `bunx basalt-ui check-coverage` is a self-consistency gate for the
package/CHANGELOG.md CHANGED
@@ -1,3 +1,31 @@
1
+ ## [1.23.1](https://github.com/jkrumm/basalt-ui/compare/v1.23.0...v1.23.1) (2026-08-22)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * gate the two chart tag rules on where the tag came from ([0075224](https://github.com/jkrumm/basalt-ui/commit/007522416bf9a3269e5f45b7ae7f45203434bae7))
7
+ * make the CLI answer which version ran, and stop it failing open ([0edf50e](https://github.com/jkrumm/basalt-ui/commit/0edf50e1d6f61b349f989fd10fe384d4a91a9af1))
8
+ * stop a typo'd band state from asserting absence ([6c1515c](https://github.com/jkrumm/basalt-ui/commit/6c1515c3ecbf019d9971e5b247e81e5cce3ae316))
9
+
10
+ # [1.23.0](https://github.com/jkrumm/basalt-ui/compare/v1.22.0...v1.23.0) (2026-08-22)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * make sync resolve, and report, what the other two commands already do ([fcc5d3b](https://github.com/jkrumm/basalt-ui/commit/fcc5d3b862e90db03be9ae3daddbde56da15e415))
16
+ * make the two annotation parsers agree, and pin the whole shape grid ([5a25825](https://github.com/jkrumm/basalt-ui/commit/5a25825342930236e35a4c8ecd1157fc6c3c8eda))
17
+ * stop a non-finite absent share painting a band that never renders ([e8fae5b](https://github.com/jkrumm/basalt-ui/commit/e8fae5b205a9f74b31289591f8948f50a3e647eb))
18
+ * teach chart-in-raw-surface the two new kinds ([fc34a3b](https://github.com/jkrumm/basalt-ui/commit/fc34a3bed4e05083288dcd22fc1e4b2cc81a1863))
19
+ * teach unframed-chart the two new kinds too ([cb4e5b7](https://github.com/jkrumm/basalt-ui/commit/cb4e5b7977ff8a141bc4ec78d3c18b515fc41fed))
20
+
21
+
22
+ ### Features
23
+
24
+ * give CartesianChart the x tick-VALUES seam its band siblings already have ([d94137d](https://github.com/jkrumm/basalt-ui/commit/d94137d20d496e8da1e0e0869c01d2d4c1577560))
25
+ * let basaltAppPlugin's icons option name a real icon file ([c0b182e](https://github.com/jkrumm/basalt-ui/commit/c0b182e580c6289b5018be9d840a588e29dea5d3))
26
+ * playground routes for BandStrip and MirroredBars ([ba6172d](https://github.com/jkrumm/basalt-ui/commit/ba6172d9ad40cf7b1e3c183e2598e79b5326c2e7))
27
+ * two banded chart kinds — BandStrip and MirroredBars ([0e4f5f7](https://github.com/jkrumm/basalt-ui/commit/0e4f5f7107e71be92eb3b6bec6ebf5587d5c607b))
28
+
1
29
  # [1.22.0](https://github.com/jkrumm/basalt-ui/compare/v1.21.0...v1.22.0) (2026-08-22)
2
30
 
3
31
 
package/MIGRATING.md CHANGED
@@ -8,9 +8,14 @@ renamed exports per minor, with the replacement.
8
8
  Reconstructed from `git diff` over the published export surface across `v1.0.0..v1.19.1`, then
9
9
  cross-checked against the repo's `scripts/export-surface.json` snapshot. **Every replacement below
10
10
  was re-audited against the built declaration files at 1.20.0 (2026-08-22)** after round 5 caught one
11
- row that was wrong. That pass corrected 4 table rows and 3 prose claims. The 1.21.0 and 1.21.1
12
- sections were written against source, not against their commit messages. Check the types, not this
13
- table, if the two disagree.
11
+ row that was wrong. That pass corrected 4 table rows and 3 prose claims. Every section from 1.21.0
12
+ on was written against source, not against its commit messages — which is not a formality: one
13
+ 1.23.0 commit message describes a change it did not make (see `Unreleased` § Corrections). Check
14
+ the types, not this table, if the two disagree.
15
+
16
+ **The newest section is headed `## Unreleased`, and stays that way until npm serves it.** This file
17
+ is written before `semantic-release` picks the number, so a number written here is a guess — and it
18
+ was wrong three rounds running.
14
19
 
15
20
  **No majors, by policy.** A rename or a removal ships as a plain `feat:` on the 1.x line, so a minor
16
21
  bump can require code changes. Skipping several at once is the expensive case — read every section
@@ -20,12 +25,196 @@ between your version and the target.
20
25
  > `## [1.` finds only the patches.
21
26
 
22
27
  **Minors with no public API delta:** 1.1.0, 1.3.0, 1.4.0, 1.5.0, 1.6.0, 1.7.0, 1.8.0, 1.9.0,
23
- 1.10.0, 1.13.0, 1.14.0, 1.16.0, 1.18.0 — and every patch except 1.21.1. Additive-only subpaths: `./tokens.css`
28
+ 1.10.0, 1.13.0, 1.14.0, 1.16.0, 1.18.0 — and every patch. Additive-only subpaths: `./tokens.css`
24
29
  at 1.3.0, `./agent-chat` at 1.10.0.
25
30
 
26
31
  ---
27
32
 
28
- ## 1.21.1 — the toolchain stops overclaiming
33
+ ## Unreleased
34
+
35
+ **The heading has no number on purpose.** Three rounds running, the newest heading here named a
36
+ version npm never served — `1.20.1`, then `1.21.1` — because this file is written before
37
+ `semantic-release` computes the number, and a `feat:` in the batch turns the guess into a phantom.
38
+ `shipped-versions.test.ts` now fails any file `init`/`sync` copy into a consumer that names a
39
+ version `CHANGELOG.md` does not record. This file is not in that set; the same discipline applies
40
+ by convention. Rename the section at release, or leave it — a reader can resolve `Unreleased`
41
+ against `CHANGELOG.md`, and could never resolve `1.21.1`.
42
+
43
+ **One type widened, nothing removed or renamed.** `BandStripSeries.formatValue` is now
44
+ `(d: T) => string | null`. `null` renders an em dash — an absent READING — which `''` never could:
45
+ `''` is a state whose label is the whole row. Every existing `(d) => string` still typechecks.
46
+
47
+ **A typo'd `BandSpan.state` no longer renders as absence.** A state naming no `series` entry used to
48
+ be skipped, so the band was simply not drawn — and on a measured/not-measured strip a missing band
49
+ is a claim about the data, not an "unknown". A misspelling asserted a reading nobody took, in a mark
50
+ indistinguishable from a real one.
51
+
52
+ | Key comes off | Dev | Production |
53
+ | ------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------ |
54
+ | the DATUM — `BandSpan.state`, `marker.state` | throws, naming the key and the valid set | a dashed neutral outline band + an `Unknown state` / `<key>` tooltip row |
55
+ | a PROP — `absentState`, `MirroredBars`' `up.key` / `down.key` | throws | throws |
56
+
57
+ The split is the whole design. `state` comes off data, so a feed that grows a state basalt has never
58
+ seen must degrade rather than take a dashboard down — while a typo, which is the same input, still
59
+ fails loudly wherever it is being written. The dashed neutral outline belongs to no legend entry and
60
+ no state fill, so it cannot read as data. Pane keys are props, never data-driven, so `assertPaneKey`
61
+ throws in every environment: an unresolvable `up.key` used to hide the pane AND its axis, which
62
+ reads as "that half measured zero" — the one thing `MirroredBars` exists to keep apart from absence.
63
+
64
+ **`chart-missing-aria-label` and `unframed-chart` are gated on where the tag came from.** Both key
65
+ on a JSX tag NAME, so a consumer's own 235-line `MirroredBars` — sharing nothing with the shipped
66
+ kind but the name — was told to pass an `ariaLabel` prop it does not accept, by a rule that presents
67
+ as a correctness finding. A tag is now skipped only when the file DEFINES a component of that name
68
+ and does not also import it from `basalt-ui`. Deliberately one-directional: a tag imported from
69
+ basalt-ui, one imported from a consumer barrel that re-exports it, and one the scan cannot attribute
70
+ at all still all fire. Verified old-vs-new over **945 source files across six repos: 0 findings
71
+ lost, 0 gained.**
72
+
73
+ **CLI: `--version` exists, and no subcommand fails open on a flag.** `--version` / `-v` / `version`
74
+ print one bare greppable line and exit 0, resolved BEFORE dispatch so it can never run a command to
75
+ answer "which basalt-ui is this". Six consumer reports reached for it in one round; all six fell
76
+ back to `info --json`.
77
+
78
+ The larger fix sits underneath it. **Every subcommand validates its flags** and exits 1 naming the
79
+ one it does not accept — `doctor --json` used to run doctor and exit **0**, and `check-theme
80
+ --audit-allow` scanned and reported success. An unknown COMMAND now says so above the usage block
81
+ instead of dumping help and letting the dump read like a choice.
82
+
83
+ - **`doctor` reads `basaltAppPlugin({ icons })`** out of the consumer's vite config instead of
84
+ hardcoding six filenames, so adopting 1.23.0's icons array stops producing a warning to generate
85
+ five files you deliberately lack. A named array is checked against itself (an icon missing from
86
+ `public/` still warns); an unparseable or absent config falls back to the six-filename check. It
87
+ can only narrow, never blind.
88
+ - **`doctor` and `sync` share one sentence about a parent install** (`parentInstallAdvice`). Run
89
+ from a package whose install is above it, `doctor` now names the parent and says `basalt-ui init`
90
+ is NOT the fix. Following its old advice literally scaffolded the second consumer that 1.22.0
91
+ exists to prevent.
92
+ - **Every seeded invocation resolves the local bin** (`basaltBinCommand`, overridable via
93
+ `BASALT_BIN`): the `lint` script, the CI steps, the `.claude` PreToolUse hook and doctor's own
94
+ advice all render `./node_modules/.bin/basalt-ui`. `bunx` does not re-resolve a package it has
95
+ cached — that is what made a round-7 report file a P0 against a 1.20.0 cache while believing it
96
+ was on 1.22.0, and the seed was shipping `bunx` into consumer CI in ten places.
97
+ `configs/lefthook.yml`'s `${BASALT_BIN:-bunx --no-install basalt-ui}` default is unchanged and
98
+ deliberate: `--no-install` fails loudly instead of downloading a stranger.
99
+ - **`tokens:css --check` blanks the provenance line (line 2) before comparing**, so a version bump
100
+ alone no longer forces a no-op commit in a tokens-only consumer, where the gate is byte-equality.
101
+ A stale provenance line is now a note on an otherwise-passing check. The `@generated` header is
102
+ still emitted byte-identical — the line stays, it just stops gating.
103
+
104
+ ### Corrections to the record
105
+
106
+ - **All six round-8 reports said `basalt-ui --version` "exits 0 printing usage". It exited 1, to
107
+ stderr** — the dispatcher's `default:` branch has always been `console.error(USAGE); return 1`.
108
+ The real fail-open was an unknown FLAG, which no report tested, and the misdiagnosis was relayed
109
+ verbatim into the fix brief. Report the symptom you measured, not the one you inferred from it.
110
+ - **`cb4e5b7`'s message is wrong** (`b9b99a6` on `origin/feat/round-7-band-kinds` is the same commit
111
+ pre-merge), and it is on `master` where it cannot be rewritten. It claims it taught
112
+ `unframed-chart` the two new kinds; it widened `CHART_ENTRY_POINT_TAG`, which only
113
+ `chart-missing-aria-label` reads. `unframed-chart` keys on `<ChartLegend items={[` and carries no
114
+ kind list at all — there was never an asymmetric pair to fix.
115
+ - **Open question, not a plan.** The import gate does not make `CHART_ENTRY_POINT_TAG` or the oxlint
116
+ plugin's `CHART_TAGS` redundant: that list still answers _which_ tags owe an `ariaLabel`. The gate
117
+ only converts a kind missing from the list from a false positive into an under-report. Collapsing
118
+ the two lists is a separate, larger change and has not been made.
119
+
120
+ ## 1.23.0 — two band kinds, an x-tick seam, CLI resolution
121
+
122
+ **No export removed or renamed; six new runtime exports and nine new types.** Nothing you wrote
123
+ changed. Every entry is additive, and the batch runs the other way from the last three: those made
124
+ the guard stricter, this one makes the framework more expressive.
125
+
126
+ **Two chart kinds `CartesianChart` structurally could not host.** It renders `AxisLeftNumeric`
127
+ unconditionally and builds x as `scalePoint` — positions, no widths. `BandStrip` has no y dimension
128
+ to axis; `MirroredBars` has two, and needs band widths. Both compose `ChartFrame` directly and
129
+ declare themselves with `theme-allow-file hand-rolled-plot`, alongside `DualPanel`.
130
+
131
+ | New on `basalt-ui/charts` | Note |
132
+ | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
133
+ | `BandStrip` (+ `BandStripProps`, `BandStripSeries`, `BandSpan`) | 1-D categorical bands. `getBand(d) => { state, fill?, absentFraction?, marker? }`; `series` IS the state set, so a strip cannot name a state it does not draw. `cursorResolution` defaults `'leading'` |
134
+ | `MirroredBars` (+ `MirroredBarsProps`, `MirroredBarPane`) | two bar panes, one x scale, one baseline, independent domains. `up`/`down` take `{ key, max?, autoMaxFloor?, ticks?, format }`; plus `upFraction` (the up pane's share of the band height, where the baseline sits), `getAbsentFraction`, `getBarOpacity` |
135
+ | `foldBands` (+ `BandFold`, `BandTooltipConfig`, `BandTooltipRowContext`) | the width-driven fold both kinds run, exported so a consumer can test their `merge` against the real grouping |
136
+ | `HatchPattern`, `hatchFill`, `hatchSizeFor` | the absence fill |
137
+
138
+ The shared choreography lives in an internal `useBandPlot` — **not exported**, deliberately: it is
139
+ the kinds' contract with each other, not a public seam.
140
+
141
+ **`MirroredBars` reverses a recorded decision, and the recorded reason for it was wrong.** The old
142
+ entry in `docs/CHARTS-SPEC.md` read _"no two-bar-pane kind with independent per-pane scales"_, with
143
+ _"a second consumer asks"_ as its trigger. That trigger never fired. Round 4 framed the blocker as
144
+ independent SCALES; `DualPanel` already had `topYDomain`/`bottomYDomain`, so that was never it. The
145
+ real blockers are that `DualPanel`'s top pane is a LINE pane and its bottom takes one SIGNED
146
+ `getBar` over a symmetric domain. A decision recorded against the wrong blocker outlives its own
147
+ refutation.
148
+
149
+ **`xTickValues?: (keys, xMax) => readonly string[]` on `CartesianChart`**, forwarded by `Bars`,
150
+ `MultiLine`, `StackedArea`, `ZonedLine` and both band kinds. It resolves AHEAD of `xTicks`, which is
151
+ unchanged and still works; omit both and ticks are chosen to fit (`smartTicks`), exactly as before.
152
+ Reach for it on a dense time axis: the tick choosers append the final key unconditionally, so a
153
+ COUNT that does not land on the last index paints two labels on top of each other at the right edge
154
+ — at every count, not at an unlucky one. On the consumer that reported it, the local tick helper
155
+ went 200 → 170 lines; it shrinks, it does not disappear. (The promise was 160. The 10-line miss is
156
+ docblock — the port's own JSDoc grew, because the helper stopped being a fallback and became the one
157
+ seam every chart on the page passes through.)
158
+
159
+ **`basaltAppPlugin`'s `icons` takes an array now.**
160
+
161
+ ```ts
162
+ icons?: false | { dir?: string } | readonly BasaltAppIcon[]
163
+ // BasaltAppIcon = { src, sizes?, type?, purpose?, rel? }
164
+ icons: [{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' }]
165
+ ```
166
+
167
+ Every entry becomes a manifest icon; an entry reaches the head only when it names a `rel` (`'icon' |
168
+ 'shortcut icon' | 'apple-touch-icon' | 'mask-icon'`) — which is what lets an app whose `index.html`
169
+ already links its favicon take the generated manifest without a duplicate tag. An empty array reads
170
+ as `false`, i.e. no `icons` member rather than an empty one. `{ dir }` and the default are
171
+ byte-identical to before. **If you kept a hand-written `manifest.webmanifest` because the plugin
172
+ could not name your icon, you can delete it now** — with its permanent `theme-allow-file` — the
173
+ generated manifest reproduces it member for member, plus an `id` a hand-written copy usually lacks.
174
+
175
+ **CLI: a repo root with no `workspaces` field was invisible to everything.** With the install one
176
+ level down and nothing declaring it, `check-theme` printed "no off-palette colors" having scanned
177
+ **zero files** and `doctor` inferred `tokens-only` for a full Mantine consumer. `resolveProjectDir`
178
+ now falls back to a bounded two-level layout scan when there is nothing declared to read; a declared
179
+ `workspaces` still wins, and 2+ candidates stays ambiguous. `BASALT_CWD` is honoured by all three
180
+ commands now, not two.
181
+
182
+ - **`sync` is profile-aware.** A `"basalt": { "profile": "tokens-only" }` consumer has no scaffold
183
+ to reconcile, so `sync` prints `n/a` and exits 0 — `sync --check` is now wirable into a
184
+ tokens-only repo's CI. It used to refuse and prescribe `basalt-ui init`, the one command that
185
+ would have written a competing install.
186
+ - **`DESIGN.md` version rot is healed, not re-stamped.** The stamp was never a constant: `DESIGN.md`
187
+ is a **seed**, written once and never reconciled, so the same line read 1.0.0 / 1.9.0 / 1.21.0 /
188
+ 1.22.0 under one install. Reported as four doc bugs over three rounds; it was one. The template
189
+ names no version and `sync` rewrites openers already written.
190
+
191
+ **The `theme-allow` shape grid is enumerated, not collected.** The reported fifth hole was three:
192
+ the closer-alone-on-its-line shape that got reported, a `MAX_COMMENT_BLOCK_LINES = 8` budget
193
+ truncating the walk inside a ~12-line docblock (not a JSX shape at all), and the plugin requiring
194
+ the annotation's comment to be the LAST one above the node — so a reason wrapped onto a second `//`,
195
+ a shape argo writes, reported under oxlint while the guard waived it. The grid now runs the four
196
+ axes that vary (comment style × token position × where the closer falls × what follows), pinned row
197
+ for row in `src/guard/check-source.test.ts` (37 supported + 8 asserted-unsupported) and
198
+ `configs/oxlint-plugin.test.ts` (32 + 8; the guard's extra five rows are CSS/HTML/JSON dialects
199
+ oxlint never sees). Zero disagreements, down from five — **and no waiver tally moved in any of the
200
+ seven consumer repos.**
201
+
202
+ Asserting the unsupported cells is the point: it stops "unsupported" and "silently broken" reading
203
+ the same. What does NOT waive, in both halves:
204
+
205
+ | Shape | Why |
206
+ | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
207
+ | a blank line between annotation and code — after `//`, after `{/* */}`, or after a `{/*` whose closer sits alone | a blank line is how you say "this comment is not about the next statement" |
208
+ | the token mid-sentence in a line comment, a docblock gutter, or a JSX expression comment | prose that MENTIONS the token is not an annotation — the reason it must START its comment |
209
+ | the token inside a string literal | same |
210
+ | above a multi-line OPENING tag, with the finding on a later attribute line | a waiver reaches the first line below its comment, not an arbitrary line further down |
211
+
212
+ **Corrected consumer findings.** _"`sync` scaffolds 19–20 files into a consumer root"_ **did not
213
+ reproduce** — those runs used a stale `bunx` cache of 1.20.0, and `bunx` does not re-resolve a
214
+ package it already has. Check an upgrade against the local bin. Round 6's _"`doctor` exits 0 on hard
215
+ failures"_ was a pipe artifact (`$?` read after `| tail`) and was already withdrawn.
216
+
217
+ ## 1.22.0 — the toolchain stops overclaiming
29
218
 
30
219
  **No export removed or renamed; four added.** Nothing you wrote changed. Every entry below is a
31
220
  check that reported an answer it had not earned, so a green CI on 1.21.0 was, for several of them,
@@ -74,14 +263,20 @@ stated limit is unchanged: exact-name-only, a tripwire, not coverage.
74
263
 
75
264
  **`basaltAppPlugin({ icons: false })` now omits the manifest's `icons` member too.** It skipped the
76
265
  head `<link>` icons and emitted the two PNG manifest entries anyway, so `{ manifest: true, icons:
77
- false }` shipped an installable app pointing at two 404s. **If you went hybrid over this** — the
78
- plugin for the head, a hand-written manifest for the rest — **you can drop the hand-written half.**
79
- The option's JSDoc said "skips the head `<link>` icons"; it now says what the option does.
266
+ false }` shipped an installable app pointing at two 404s. The manifest is now **honest** about
267
+ icons — it is not yet **sufficient**. If you went hybrid over the 404s, dropping the hand-written
268
+ half here costs you every icon unless your `public/` matches basalt's six filenames: at this
269
+ release `icons` was still `false | { dir?: string }` over those six, so an app whose icon is
270
+ `favicon.svg` could pick between a manifest naming two PNGs it never builds and a manifest with no
271
+ `icons` member at all. The array form that fixes it is in `## Unreleased`. The option's JSDoc said
272
+ "skips the head `<link>` icons"; it now says what the option does.
80
273
 
81
274
  **Two `theme-allow` comment shapes were silently broken and now work.** Plainly: this is the fourth
82
- hole found in this one contract in three rounds. The full thirteen-shape matrix is now pinned in
83
- both halves — `src/guard/check-source.test.ts` and `configs/oxlint-plugin.test.ts` — so the two
84
- parsers can no longer disagree about what an annotation IS.
275
+ hole found in this one contract in three rounds. A thirteen-shape matrix — every shape a consumer
276
+ had been SEEN to write — is pinned in both halves, `src/guard/check-source.test.ts` and
277
+ `configs/oxlint-plugin.test.ts`. **The claim that followed it here, that the two parsers could
278
+ therefore no longer disagree, was false when it was written**: a list of collected anecdotes cannot
279
+ close a contract. Three more holes were open at this release. See `## Unreleased`.
85
280
 
86
281
  | Shape | What it did | Now |
87
282
  | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
@@ -378,7 +573,7 @@ deleting an entry forces the level flip in the same commit.
378
573
  | `basalt/raw-size-literal` | 1.7.0 as `warn` | **1.20.0** |
379
574
  | `basalt/hand-rolled-plot` | 1.15.0 as `warn` | still `warn` — widened again at 1.21.0; grace restarts with the widening |
380
575
  | `basalt/chart-legend-literal` | 1.15.0 as `warn` | still `warn` — widened at 1.20.0 |
381
- | `basalt/shadow-basalt-export` | 1.20.0 as `warn` | may stay `warn` permanently — widened at 1.21.0, narrowed at 1.21.1 |
576
+ | `basalt/shadow-basalt-export` | 1.20.0 as `warn` | may stay `warn` permanently — widened at 1.21.0, narrowed at 1.22.0 |
382
577
  | `basalt/hand-rolled-shell` | 1.20.0 as `warn` | — |
383
578
 
384
579
  `card-with-border`, `inline-display`, `raw-html-layout`, `raw-form-control`, `raw-font-family` and
package/README.md CHANGED
@@ -261,7 +261,7 @@ nothing, so a typo is never the blanket form.
261
261
 
262
262
  `basalt-ui check-theme --audit-allows` reports every annotation and every `exemptRules` entry with
263
263
  what it still suppresses, proved by re-running the scan with that one waiver neutralized. Exits 1 on
264
- a dead waiver, so it is usable as a CI gate. Since 1.21.1 it runs **both** halves — `checkSource` for
264
+ a dead waiver, so it is usable as a CI gate. Since 1.22.0 it runs **both** halves — `checkSource` for
265
265
  a guard kind, oxlint over one neutralized sibling file for a plugin rule — so a waiver naming
266
266
  `hand-rolled-plot` is judged rather than skipped. **It needs oxlint reachable**; where it is not, the
267
267
  verdict is "cannot judge", never "dead". The report prints the scope it audited, because `0 dead`
@@ -441,8 +441,30 @@ import { CartesianChart, LinePath, VX } from 'basalt-ui/charts'
441
441
  </CartesianChart>
442
442
  ```
443
443
 
444
- Shipped kinds (`Bars`, `MultiLine`, `StackedArea`, `ZonedLine`, `Donut`, `DualPanel`, `Heatmap`)
445
- are that same composition, pre-wired.
444
+ Shipped kinds (`Bars`, `MultiLine`, `StackedArea`, `ZonedLine`, `Donut`, `DualPanel`, `Heatmap`,
445
+ `BandStrip`, `MirroredBars`) are that same composition, pre-wired. The last five compose
446
+ `ChartFrame` directly, because a ring, a matrix, two panes or a strip is not a single plot rect:
447
+ **`BandStrip`** draws 1-D categorical bands with no y dimension at all (`CartesianChart` renders a
448
+ left numeric axis unconditionally, so it could never host one), and **`MirroredBars`** draws two
449
+ bar panes over one x scale and one baseline, each in its own domain. Both fold their domain by
450
+ width, hatch the share of a slot nothing measured, and share the page cursor like every other
451
+ chart.
452
+
453
+ On a strip, `series` IS the state set, so a `state` naming no entry has no colour, no legend entry
454
+ and no tooltip row. It **throws in dev**, naming the key and the valid set; in production it draws a
455
+ dashed neutral outline band — a treatment no legend entry and no state fill uses — plus an
456
+ `Unknown state` tooltip row. Drawing nothing was the old behaviour and the wrong one: on a
457
+ measured/not-measured strip a missing band claims a coverage gap. `absentState` and `MirroredBars`'
458
+ `up.key`/`down.key` are PROPS rather than data, so those throw in every environment; an
459
+ unresolvable pane key used to hide the pane and its axis, which reads as a measured zero.
460
+ `BandStripSeries.formatValue` returns `string | null`, `null` rendering an em dash — an absent
461
+ reading, distinct from `''`, which is a state whose label is the whole row.
462
+
463
+ Which x ticks get painted is `xTickValues?: (keys, xMax) => readonly string[]`, on
464
+ `CartesianChart` and forwarded by `Bars`/`MultiLine`/`StackedArea`/`ZonedLine` and both band kinds.
465
+ It resolves ahead of the `xTicks` COUNT, which is unchanged; omit both and ticks are chosen to fit.
466
+ Reach for values on a dense time axis — the tick chooser appends the final key unconditionally, so
467
+ a count that misses the last index collides two labels at the right edge at every count.
446
468
 
447
469
  ### `./query` — TanStack Query adapter
448
470
 
@@ -644,8 +666,15 @@ bun add -D vite-plugin-pwa workbox-build workbox-window # only if serviceWorke
644
666
  Without the peer installed, `serviceWorker: true` degrades to a one-line console warning (no
645
667
  service worker, no crash) — everything else (theme-color meta, anti-FOUC background,
646
668
  `site.webmanifest`, favicon/apple-touch-icon links, OG/Twitter defaults) works with zero extra
647
- dependencies. Full doctrine, plugin ordering, and the bring-your-own icon filenames:
648
- `agent/rules/basalt-app.md`.
669
+ dependencies.
670
+
671
+ `icons` takes `false | { dir?: string } | readonly BasaltAppIcon[]`. The array form names the icons
672
+ the app actually has, with the manifest's own field names (`src`, `sizes`, `type`, `purpose`) plus
673
+ an optional `rel` — every entry becomes a manifest icon, and only an entry naming a `rel` reaches
674
+ the head. `icons: [{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' }]` is a complete,
675
+ installable icon set for a single-page app. An empty array reads as `false`; `{ dir }` and the
676
+ default are byte-identical to what they always emitted. Full doctrine, plugin ordering, and the six
677
+ default icon filenames: `agent/rules/basalt-app.md`.
649
678
 
650
679
  ---
651
680
 
@@ -675,34 +704,58 @@ Current slots: `series` (read by `./charts` + `./tokens`), `commands` (read by `
675
704
  ## CLI
676
705
 
677
706
  ```bash
678
- bunx basalt-ui init # scaffold doctrine into a consumer repo
679
- bunx basalt-ui sync # three-way diff against .basalt/manifest.json after a basalt-ui upgrade
680
- bunx basalt-ui sync --check # CI drift gate — non-zero exit on any managed-file drift
681
- bunx basalt-ui check-theme # palette guard — fails on colors that bypass the central --vx-* system
682
- bunx basalt-ui doctor # integration health: basalt-resolves, guard-scan, oxlint-preset, manifest presence, node_modules-vs-manifest version, spacing-scale drift, and a cross-package `ai` major-version parity check (HARD failure — walks every workspace manifest, exemptable via `basalt.aiMajorSkewReason` — see above) + warn on missing basaltAppPlugin icon files under public/
683
- bunx basalt-ui doctor --tokens-only # force the Mantine-free profile (auto-detected otherwise; --framework forces the full set)
707
+ bunx basalt-ui init # scaffold doctrine into a consumer repo — the one command with no install to resolve
708
+ basalt-ui --version # one bare line, exit 0 (also -v / version) — which CLI is actually running
709
+ basalt-ui sync # three-way diff against .basalt/manifest.json after a basalt-ui upgrade
710
+ basalt-ui sync --check # CI drift gate — non-zero exit on any managed-file drift
711
+ basalt-ui check-theme # palette guard — fails on colors that bypass the central --vx-* system
712
+ basalt-ui doctor # integration health: basalt-resolves, guard-scan, oxlint-preset, lefthook-preset, manifest presence, node_modules-vs-manifest version, spacing-scale drift, and a cross-package `ai` major-version parity check (HARD failure — walks every workspace manifest, exemptable via `basalt.aiMajorSkewReason` — see above) + warn on an icon file basaltAppPlugin references and public/ does not have
713
+ basalt-ui doctor --tokens-only # force the Mantine-free profile (auto-detected otherwise; --framework forces the full set)
684
714
  bunx basalt-ui tokens:css --out src/tokens.css # emit the --vx-* stylesheet as a file you own
685
715
  bunx basalt-ui fonts:css --out src/fonts.css # emit the shipped --basalt-font-* stacks
686
- bunx basalt-ui info # human-readable surface map: subpath, layer, rule, skills, optional peers
687
- bunx basalt-ui info --json # same map as stable JSON (InfoOutput shape)
688
- bunx basalt-ui help # full usage; every subcommand also takes --help / -h
716
+ basalt-ui info --json # surface map as stable JSON (InfoOutput shape); bare `info` is the human-readable form
717
+ basalt-ui help # full usage; every subcommand also takes --help / -h
689
718
  ```
690
719
 
720
+ **Nothing fails open.** `--version` resolves before dispatch, so it can never run a command to
721
+ answer which CLI ran. Every subcommand validates its flags and exits 1 naming the first one it does
722
+ not accept — `doctor --json` used to run doctor and exit **0**, and `check-theme --audit-allow`
723
+ scanned and reported success. An unknown command says so above the usage block.
724
+
691
725
  `doctor` reports **`SKIPPED`** as a third outcome beside pass/warn/fail and exits non-zero on it —
692
- "All checks passed" is only printable when every check actually ran. `check-theme` and `doctor`
693
- honour `BASALT_CWD`, and relocate to the single workspace package carrying a basalt config when
694
- invoked from a repo root that has none (two candidates is reported as ambiguous, never guessed).
695
- `tokens:css` and `fonts:css` both take `--check` as a CI drift gate.
726
+ "All checks passed" is only printable when every check actually ran. **`check-theme`, `doctor` and
727
+ `sync` all resolve their project the same way**: `BASALT_CWD` wins, then the cwd itself, then the
728
+ workspace packages the root declares, then a two-level layout scan. Two candidates is reported as
729
+ ambiguous, never guessed. The scan is what a repo declaring **no `workspaces` field** needs — with
730
+ the install one level down and nothing declaring it, every command answered about the root and
731
+ found nothing there: `check-theme` printed "no off-palette colors" having scanned zero files, and
732
+ `doctor` inferred `tokens-only` for a full Mantine consumer. `tokens:css` and `fonts:css` both take
733
+ `--check` as a CI drift gate, comparing everything but the header's provenance line — so a
734
+ basalt-ui version bump alone never forces a no-op commit in a tokens-only consumer.
735
+
736
+ **Run the local bin, not `bunx`.** `bunx` does not re-resolve a package it has already cached, so a
737
+ `bunx basalt-ui …` run can execute a version you upgraded away from — which is how one consumer
738
+ filed a P0 against a 1.20.0 cache while pinned to 1.22.0. `init` and `sync` now render every seeded
739
+ script, CI step, hook and piece of doctor advice with the resolved `./node_modules/.bin/basalt-ui`
740
+ (`BASALT_BIN` overrides it, and `configs/lefthook.yml` keeps `bunx --no-install` as a deliberately
741
+ loud fallback). `basalt-ui --version` is the one-second check.
696
742
 
697
743
  `sync` strategy per file: unchanged since last write → overwrite; locally edited → skip (show diff);
698
744
  missing → recreate. `--force` overwrites local edits. The summary counts `created` and `recreated`
699
- apart: `recreated` means the ledger placed that file once and it went missing.
745
+ apart: `recreated` means the ledger placed that file once and it went missing. On a
746
+ `"basalt": { "profile": "tokens-only" }` consumer there is no scaffold to reconcile, so `sync`
747
+ prints `n/a` and exits 0 — `sync --check` is wirable into a tokens-only repo's CI without a
748
+ special case. `sync` also heals a `DESIGN.md` opener that still names a version: the file is a
749
+ **seed**, written once and never reconciled, so any number stamped into it was stale by the next
750
+ release.
700
751
 
701
752
  **`sync` refreshes an existing install; it does not create one.** It resolves its project exactly as
702
- `check-theme` and `doctor` do, and since 1.21.1 **exits 1** when the resolved project has no
753
+ `check-theme` and `doctor` do, and since 1.22.0 **exits 1** when the resolved project has no
703
754
  `.basalt/manifest.json`, naming the install it found above instead of scaffolding a second one
704
755
  beside it. Run it at the package that holds the manifest, or point `BASALT_CWD` at that package.
705
- Creating an install is `basalt-ui init`'s decision.
756
+ Creating an install is `basalt-ui init`'s decision — and `doctor` now says the same sentence from
757
+ the same directory, where it used to prescribe `init` and so recommend the very scaffold that
758
+ refusal exists to prevent.
706
759
 
707
760
  ---
708
761
 
@@ -99,7 +99,7 @@ Run `basalt-ui doctor` afterwards to confirm the wiring took. A check that canno
99
99
  ### The lefthook preset overrides YOU, not the other way round
100
100
 
101
101
  `doctor`'s `lefthook-preset` check asks whether a pre-commit gate EXISTS — not whether your config
102
- contains the extends string. Since 1.21.1 it asks `lefthook dump`, which resolves `extends`,
102
+ contains the extends string. Since 1.22.0 it asks `lefthook dump`, which resolves `extends`,
103
103
  `include` and per-command `root:`. **Wiring the jobs yourself passes**: linewatch runs all three with
104
104
  `root: 'web/'` precisely because `extends` merges commands _without_ their working directory, and the
105
105
  old text match warned there and prescribed a change that would have broken it.
@@ -193,10 +193,40 @@ web-app-manifest-512x512.png
193
193
 
194
194
  Generate the set with `@vite-pwa/assets-generator` (or realfavicongenerator.net) from a single
195
195
  source image — it lives in the CONSUMER's own devDependencies, not basalt-ui's. `basalt-ui doctor`
196
- warns (does not fail) when `public/` exists but is missing one of these files.
196
+ warns (does not fail) when `public/` exists but is missing one of these files — and it now
197
+ parses `basaltAppPlugin`'s `icons` option out of your vite config first, so it checks the files you
198
+ declared rather than the six it assumes. `icons: false` or an empty array is "nothing to check"; an
199
+ unparseable or absent config falls back to the six defaults. Adopting the array is no longer a way
200
+ to earn a warning.
201
+
202
+ **If your `public/` does not match those six filenames, name your icons instead.** `icons` takes
203
+ `false | { dir?: string } | readonly BasaltAppIcon[]`, and an array uses the manifest's own field
204
+ names verbatim:
205
+
206
+ ```ts
207
+ basaltAppPlugin({
208
+ name: 'rb',
209
+ icons: [{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' }],
210
+ })
211
+ ```
212
+
213
+ `BasaltAppIcon` is `{ src, sizes?, type?, purpose?, rel? }`, `rel` being one of `'icon'`,
214
+ `'shortcut icon'`, `'apple-touch-icon'`, `'mask-icon'`. **Every entry becomes a manifest icon; an
215
+ entry reaches the head only when it names a `rel`** — which is what lets an app whose `index.html`
216
+ already links its favicon take the generated manifest without a duplicate tag. An empty array reads
217
+ as `false` (no `icons` member, rather than an empty one). `{ dir }` and the default are byte-identical
218
+ to what they always emitted.
219
+
220
+ One SVG at `sizes: 'any'` is a complete, installable icon set for a single-page app — and it is the
221
+ shape every non-scaffolded consumer already has. If you keep a hand-written `manifest.webmanifest`
222
+ only because the plugin could not name your icon, delete it: the generated manifest reproduces it
223
+ member for member, including the two hexes derived from `SURFACE.bg` rather than hand-copied, and it
224
+ takes the permanent `theme-allow-file` on that file with it. **Delete that app's own
225
+ `<link rel="manifest">` in the same change** — the plugin injects its own, so leaving both ships two
226
+ manifest links pointing at different files, one of them a 404 the moment the hand-written file goes.
197
227
 
198
228
  `basaltAppPlugin` also emits `site.webmanifest` (served in dev too) with explicit `id`/`scope`/
199
- `start_url` — pass `manifest: false` to skip it, or `icons: false` to skip the icons. Since 1.21.1
229
+ `start_url` — pass `manifest: false` to skip it, or `icons: false` to skip the icons. Since 1.22.0
200
230
  `icons: false` omits BOTH the head `<link>` tags and the manifest's `icons` member; before that it
201
231
  skipped only the head, so a manifest shipped naming two PNGs the app never builds.
202
232