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
package/AGENTS.md CHANGED
@@ -75,6 +75,14 @@ bunx basalt-ui info --json # stable JSON surface map
75
75
  bunx basalt-ui guard-hook # PreToolUse theme-guard adapter: reads a Write/Edit payload on stdin, denies off-palette writes
76
76
  ```
77
77
 
78
+ `check-theme`, `doctor` and `sync` resolve their project identically: `BASALT_CWD` wins, then the
79
+ cwd, then the workspace packages the root declares, then a two-level layout scan (which is what a
80
+ repo with no `workspaces` field and the install one level down needs). Two candidates is ambiguous
81
+ and reported, never guessed. `sync` on a `tokens-only` consumer prints `n/a` and exits 0.
82
+
83
+ **Checking what an upgrade actually does? Run the local bin, not `bunx`.** `bunx` does not
84
+ re-resolve a cached package, so it can execute the version you just upgraded away from.
85
+
78
86
  Register `guard-hook` in `.claude/settings.json` under `hooks.PreToolUse` with matcher
79
87
  `Write|Edit|MultiEdit` and command `bunx basalt-ui guard-hook` so the agent can't write off-palette
80
88
  colors without a `theme-allow` comment.
@@ -88,7 +96,7 @@ over the declaration it terminates. Whole-file scope is `theme-allow-file <id>
88
96
  reports `theme-allow-unscoped`. A word in the id slot that names no rule waives nothing.
89
97
  `.json`/`.webmanifest` use a `"basalt:theme-allow[-file]"` member. Audit them all with
90
98
  `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;
99
+ plugin-rule waivers too (since 1.22.0), by re-running oxlint — so it needs oxlint reachable;
92
100
  otherwise the verdict is "cannot judge", never "dead".
93
101
 
94
102
  > **Framework-internal only** — `bunx basalt-ui check-coverage` is a self-consistency gate for the
package/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ # [1.23.0](https://github.com/jkrumm/basalt-ui/compare/v1.22.0...v1.23.0) (2026-08-22)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * make sync resolve, and report, what the other two commands already do ([fcc5d3b](https://github.com/jkrumm/basalt-ui/commit/fcc5d3b862e90db03be9ae3daddbde56da15e415))
7
+ * make the two annotation parsers agree, and pin the whole shape grid ([5a25825](https://github.com/jkrumm/basalt-ui/commit/5a25825342930236e35a4c8ecd1157fc6c3c8eda))
8
+ * stop a non-finite absent share painting a band that never renders ([e8fae5b](https://github.com/jkrumm/basalt-ui/commit/e8fae5b205a9f74b31289591f8948f50a3e647eb))
9
+ * teach chart-in-raw-surface the two new kinds ([fc34a3b](https://github.com/jkrumm/basalt-ui/commit/fc34a3bed4e05083288dcd22fc1e4b2cc81a1863))
10
+ * teach unframed-chart the two new kinds too ([cb4e5b7](https://github.com/jkrumm/basalt-ui/commit/cb4e5b7977ff8a141bc4ec78d3c18b515fc41fed))
11
+
12
+
13
+ ### Features
14
+
15
+ * give CartesianChart the x tick-VALUES seam its band siblings already have ([d94137d](https://github.com/jkrumm/basalt-ui/commit/d94137d20d496e8da1e0e0869c01d2d4c1577560))
16
+ * let basaltAppPlugin's icons option name a real icon file ([c0b182e](https://github.com/jkrumm/basalt-ui/commit/c0b182e580c6289b5018be9d840a588e29dea5d3))
17
+ * playground routes for BandStrip and MirroredBars ([ba6172d](https://github.com/jkrumm/basalt-ui/commit/ba6172d9ad40cf7b1e3c183e2598e79b5326c2e7))
18
+ * two banded chart kinds — BandStrip and MirroredBars ([0e4f5f7](https://github.com/jkrumm/basalt-ui/commit/0e4f5f7107e71be92eb3b6bec6ebf5587d5c607b))
19
+
1
20
  # [1.22.0](https://github.com/jkrumm/basalt-ui/compare/v1.21.0...v1.22.0) (2026-08-22)
2
21
 
3
22
 
package/MIGRATING.md CHANGED
@@ -8,9 +8,13 @@ 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. The 1.21.0, 1.22.0 and
12
+ `Unreleased` sections were written against source, not against their commit messages. Check the
13
+ types, not this table, if the two disagree.
14
+
15
+ **The newest section is headed `## Unreleased`, and stays that way until npm serves it.** This file
16
+ is written before `semantic-release` picks the number, so a number written here is a guess — and it
17
+ was wrong three rounds running.
14
18
 
15
19
  **No majors, by policy.** A rename or a removal ships as a plain `feat:` on the 1.x line, so a minor
16
20
  bump can require code changes. Skipping several at once is the expensive case — read every section
@@ -20,12 +24,115 @@ between your version and the target.
20
24
  > `## [1.` finds only the patches.
21
25
 
22
26
  **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`
27
+ 1.10.0, 1.13.0, 1.14.0, 1.16.0, 1.18.0 — and every patch. Additive-only subpaths: `./tokens.css`
24
28
  at 1.3.0, `./agent-chat` at 1.10.0.
25
29
 
26
30
  ---
27
31
 
28
- ## 1.21.1 — the toolchain stops overclaiming
32
+ ## Unreleased
33
+
34
+ **The heading has no number on purpose.** Three rounds running, the newest heading here named a
35
+ version npm never served — `1.20.1`, then `1.21.1` — because this file is written before
36
+ `semantic-release` computes the number, and a `feat:` in the batch turns the guess into a phantom.
37
+ `shipped-versions.test.ts` now fails any file `init`/`sync` copy into a consumer that names a
38
+ version `CHANGELOG.md` does not record. This file is not in that set; the same discipline applies
39
+ by convention. Rename the section at release, or leave it — a reader can resolve `Unreleased`
40
+ against `CHANGELOG.md`, and could never resolve `1.21.1`.
41
+
42
+ **No export removed or renamed; six new runtime exports and nine new types.** Nothing you wrote
43
+ changed. Every entry is additive, and the batch runs the other way from the last three: those made
44
+ the guard stricter, this one makes the framework more expressive.
45
+
46
+ **Two chart kinds `CartesianChart` structurally could not host.** It renders `AxisLeftNumeric`
47
+ unconditionally and builds x as `scalePoint` — positions, no widths. `BandStrip` has no y dimension
48
+ to axis; `MirroredBars` has two, and needs band widths. Both compose `ChartFrame` directly and
49
+ declare themselves with `theme-allow-file hand-rolled-plot`, alongside `DualPanel`.
50
+
51
+ | New on `basalt-ui/charts` | Note |
52
+ | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
53
+ | `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'` |
54
+ | `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` |
55
+ | `foldBands` (+ `BandFold`, `BandTooltipConfig`, `BandTooltipRowContext`) | the width-driven fold both kinds run, exported so a consumer can test their `merge` against the real grouping |
56
+ | `HatchPattern`, `hatchFill`, `hatchSizeFor` | the absence fill |
57
+
58
+ The shared choreography lives in an internal `useBandPlot` — **not exported**, deliberately: it is
59
+ the kinds' contract with each other, not a public seam.
60
+
61
+ **`MirroredBars` reverses a recorded decision, and the recorded reason for it was wrong.** The old
62
+ entry in `docs/CHARTS-SPEC.md` read _"no two-bar-pane kind with independent per-pane scales"_, with
63
+ _"a second consumer asks"_ as its trigger. That trigger never fired. Round 4 framed the blocker as
64
+ independent SCALES; `DualPanel` already had `topYDomain`/`bottomYDomain`, so that was never it. The
65
+ real blockers are that `DualPanel`'s top pane is a LINE pane and its bottom takes one SIGNED
66
+ `getBar` over a symmetric domain. A decision recorded against the wrong blocker outlives its own
67
+ refutation.
68
+
69
+ **`xTickValues?: (keys, xMax) => readonly string[]` on `CartesianChart`**, forwarded by `Bars`,
70
+ `MultiLine`, `StackedArea`, `ZonedLine` and both band kinds. It resolves AHEAD of `xTicks`, which is
71
+ unchanged and still works; omit both and ticks are chosen to fit (`smartTicks`), exactly as before.
72
+ Reach for it on a dense time axis: the tick choosers append the final key unconditionally, so a
73
+ COUNT that does not land on the last index paints two labels on top of each other at the right edge
74
+ — at every count, not at an unlucky one. On the consumer that reported it, the local tick helper
75
+ went 200 → 160 lines; it shrinks, it does not disappear.
76
+
77
+ **`basaltAppPlugin`'s `icons` takes an array now.**
78
+
79
+ ```ts
80
+ icons?: false | { dir?: string } | readonly BasaltAppIcon[]
81
+ // BasaltAppIcon = { src, sizes?, type?, purpose?, rel? }
82
+ icons: [{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' }]
83
+ ```
84
+
85
+ Every entry becomes a manifest icon; an entry reaches the head only when it names a `rel` (`'icon' |
86
+ 'shortcut icon' | 'apple-touch-icon' | 'mask-icon'`) — which is what lets an app whose `index.html`
87
+ already links its favicon take the generated manifest without a duplicate tag. An empty array reads
88
+ as `false`, i.e. no `icons` member rather than an empty one. `{ dir }` and the default are
89
+ byte-identical to before. **If you kept a hand-written `manifest.webmanifest` because the plugin
90
+ could not name your icon, you can delete it now** — with its permanent `theme-allow-file` — the
91
+ generated manifest reproduces it member for member, plus an `id` a hand-written copy usually lacks.
92
+
93
+ **CLI: a repo root with no `workspaces` field was invisible to everything.** With the install one
94
+ level down and nothing declaring it, `check-theme` printed "no off-palette colors" having scanned
95
+ **zero files** and `doctor` inferred `tokens-only` for a full Mantine consumer. `resolveProjectDir`
96
+ now falls back to a bounded two-level layout scan when there is nothing declared to read; a declared
97
+ `workspaces` still wins, and 2+ candidates stays ambiguous. `BASALT_CWD` is honoured by all three
98
+ commands now, not two.
99
+
100
+ - **`sync` is profile-aware.** A `"basalt": { "profile": "tokens-only" }` consumer has no scaffold
101
+ to reconcile, so `sync` prints `n/a` and exits 0 — `sync --check` is now wirable into a
102
+ tokens-only repo's CI. It used to refuse and prescribe `basalt-ui init`, the one command that
103
+ would have written a competing install.
104
+ - **`DESIGN.md` version rot is healed, not re-stamped.** The stamp was never a constant: `DESIGN.md`
105
+ is a **seed**, written once and never reconciled, so the same line read 1.0.0 / 1.9.0 / 1.21.0 /
106
+ 1.22.0 under one install. Reported as four doc bugs over three rounds; it was one. The template
107
+ names no version and `sync` rewrites openers already written.
108
+
109
+ **The `theme-allow` shape grid is enumerated, not collected.** The reported fifth hole was three:
110
+ the closer-alone-on-its-line shape that got reported, a `MAX_COMMENT_BLOCK_LINES = 8` budget
111
+ truncating the walk inside a ~12-line docblock (not a JSX shape at all), and the plugin requiring
112
+ the annotation's comment to be the LAST one above the node — so a reason wrapped onto a second `//`,
113
+ a shape argo writes, reported under oxlint while the guard waived it. The grid now runs the four
114
+ axes that vary (comment style × token position × where the closer falls × what follows), pinned row
115
+ for row in `src/guard/check-source.test.ts` (37 supported + 8 asserted-unsupported) and
116
+ `configs/oxlint-plugin.test.ts` (32 + 8; the guard's extra five rows are CSS/HTML/JSON dialects
117
+ oxlint never sees). Zero disagreements, down from five — **and no waiver tally moved in any of the
118
+ seven consumer repos.**
119
+
120
+ Asserting the unsupported cells is the point: it stops "unsupported" and "silently broken" reading
121
+ the same. What does NOT waive, in both halves:
122
+
123
+ | Shape | Why |
124
+ | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
125
+ | 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" |
126
+ | 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 |
127
+ | the token inside a string literal | same |
128
+ | 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 |
129
+
130
+ **Corrected consumer findings.** _"`sync` scaffolds 19–20 files into a consumer root"_ **did not
131
+ reproduce** — those runs used a stale `bunx` cache of 1.20.0, and `bunx` does not re-resolve a
132
+ package it already has. Check an upgrade against the local bin. Round 6's _"`doctor` exits 0 on hard
133
+ failures"_ was a pipe artifact (`$?` read after `| tail`) and was already withdrawn.
134
+
135
+ ## 1.22.0 — the toolchain stops overclaiming
29
136
 
30
137
  **No export removed or renamed; four added.** Nothing you wrote changed. Every entry below is a
31
138
  check that reported an answer it had not earned, so a green CI on 1.21.0 was, for several of them,
@@ -74,14 +181,20 @@ stated limit is unchanged: exact-name-only, a tripwire, not coverage.
74
181
 
75
182
  **`basaltAppPlugin({ icons: false })` now omits the manifest's `icons` member too.** It skipped the
76
183
  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.
184
+ false }` shipped an installable app pointing at two 404s. The manifest is now **honest** about
185
+ icons — it is not yet **sufficient**. If you went hybrid over the 404s, dropping the hand-written
186
+ half here costs you every icon unless your `public/` matches basalt's six filenames: at this
187
+ release `icons` was still `false | { dir?: string }` over those six, so an app whose icon is
188
+ `favicon.svg` could pick between a manifest naming two PNGs it never builds and a manifest with no
189
+ `icons` member at all. The array form that fixes it is in `## Unreleased`. The option's JSDoc said
190
+ "skips the head `<link>` icons"; it now says what the option does.
80
191
 
81
192
  **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.
193
+ hole found in this one contract in three rounds. A thirteen-shape matrix — every shape a consumer
194
+ had been SEEN to write — is pinned in both halves, `src/guard/check-source.test.ts` and
195
+ `configs/oxlint-plugin.test.ts`. **The claim that followed it here, that the two parsers could
196
+ therefore no longer disagree, was false when it was written**: a list of collected anecdotes cannot
197
+ close a contract. Three more holes were open at this release. See `## Unreleased`.
85
198
 
86
199
  | Shape | What it did | Now |
87
200
  | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
@@ -378,7 +491,7 @@ deleting an entry forces the level flip in the same commit.
378
491
  | `basalt/raw-size-literal` | 1.7.0 as `warn` | **1.20.0** |
379
492
  | `basalt/hand-rolled-plot` | 1.15.0 as `warn` | still `warn` — widened again at 1.21.0; grace restarts with the widening |
380
493
  | `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 |
494
+ | `basalt/shadow-basalt-export` | 1.20.0 as `warn` | may stay `warn` permanently — widened at 1.21.0, narrowed at 1.22.0 |
382
495
  | `basalt/hand-rolled-shell` | 1.20.0 as `warn` | — |
383
496
 
384
497
  `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,20 @@ 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
+ Which x ticks get painted is `xTickValues?: (keys, xMax) => readonly string[]`, on
454
+ `CartesianChart` and forwarded by `Bars`/`MultiLine`/`StackedArea`/`ZonedLine` and both band kinds.
455
+ It resolves ahead of the `xTicks` COUNT, which is unchanged; omit both and ticks are chosen to fit.
456
+ Reach for values on a dense time axis — the tick chooser appends the final key unconditionally, so
457
+ a count that misses the last index collides two labels at the right edge at every count.
446
458
 
447
459
  ### `./query` — TanStack Query adapter
448
460
 
@@ -644,8 +656,15 @@ bun add -D vite-plugin-pwa workbox-build workbox-window # only if serviceWorke
644
656
  Without the peer installed, `serviceWorker: true` degrades to a one-line console warning (no
645
657
  service worker, no crash) — everything else (theme-color meta, anti-FOUC background,
646
658
  `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`.
659
+ dependencies.
660
+
661
+ `icons` takes `false | { dir?: string } | readonly BasaltAppIcon[]`. The array form names the icons
662
+ the app actually has, with the manifest's own field names (`src`, `sizes`, `type`, `purpose`) plus
663
+ an optional `rel` — every entry becomes a manifest icon, and only an entry naming a `rel` reaches
664
+ the head. `icons: [{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' }]` is a complete,
665
+ installable icon set for a single-page app. An empty array reads as `false`; `{ dir }` and the
666
+ default are byte-identical to what they always emitted. Full doctrine, plugin ordering, and the six
667
+ default icon filenames: `agent/rules/basalt-app.md`.
649
668
 
650
669
  ---
651
670
 
@@ -689,17 +708,32 @@ bunx basalt-ui help # full usage; every subcommand also takes --hel
689
708
  ```
690
709
 
691
710
  `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.
711
+ "All checks passed" is only printable when every check actually ran. **`check-theme`, `doctor` and
712
+ `sync` all resolve their project the same way**: `BASALT_CWD` wins, then the cwd itself, then the
713
+ workspace packages the root declares, then a two-level layout scan. Two candidates is reported as
714
+ ambiguous, never guessed. The scan is what a repo declaring **no `workspaces` field** needs — with
715
+ the install one level down and nothing declaring it, every command answered about the root and
716
+ found nothing there: `check-theme` printed "no off-palette colors" having scanned zero files, and
717
+ `doctor` inferred `tokens-only` for a full Mantine consumer. `tokens:css` and `fonts:css` both take
718
+ `--check` as a CI drift gate.
719
+
720
+ **Verify an upgrade against the local bin, not `bunx`.** `bunx` does not re-resolve a package it
721
+ has already cached, so a `bunx basalt-ui …` run can execute a version you upgraded away from —
722
+ which is exactly how a fixed defect gets re-reported. Prefer `./node_modules/.bin/basalt-ui` (or
723
+ `bun run` a script that does) when the point of the run is to check what the installed version
724
+ does.
696
725
 
697
726
  `sync` strategy per file: unchanged since last write → overwrite; locally edited → skip (show diff);
698
727
  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.
728
+ apart: `recreated` means the ledger placed that file once and it went missing. On a
729
+ `"basalt": { "profile": "tokens-only" }` consumer there is no scaffold to reconcile, so `sync`
730
+ prints `n/a` and exits 0 — `sync --check` is wirable into a tokens-only repo's CI without a
731
+ special case. `sync` also heals a `DESIGN.md` opener that still names a version: the file is a
732
+ **seed**, written once and never reconciled, so any number stamped into it was stale by the next
733
+ release.
700
734
 
701
735
  **`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
736
+ `check-theme` and `doctor` do, and since 1.22.0 **exits 1** when the resolved project has no
703
737
  `.basalt/manifest.json`, naming the install it found above instead of scaffolding a second one
704
738
  beside it. Run it at the package that holds the manifest, or point `BASALT_CWD` at that package.
705
739
  Creating an install is `basalt-ui init`'s decision.
@@ -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.
@@ -195,8 +195,32 @@ Generate the set with `@vite-pwa/assets-generator` (or realfavicongenerator.net)
195
195
  source image — it lives in the CONSUMER's own devDependencies, not basalt-ui's. `basalt-ui doctor`
196
196
  warns (does not fail) when `public/` exists but is missing one of these files.
197
197
 
198
+ **If your `public/` does not match those six filenames, name your icons instead.** `icons` takes
199
+ `false | { dir?: string } | readonly BasaltAppIcon[]`, and an array uses the manifest's own field
200
+ names verbatim:
201
+
202
+ ```ts
203
+ basaltAppPlugin({
204
+ name: 'rb',
205
+ icons: [{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' }],
206
+ })
207
+ ```
208
+
209
+ `BasaltAppIcon` is `{ src, sizes?, type?, purpose?, rel? }`, `rel` being one of `'icon'`,
210
+ `'shortcut icon'`, `'apple-touch-icon'`, `'mask-icon'`. **Every entry becomes a manifest icon; an
211
+ entry reaches the head only when it names a `rel`** — which is what lets an app whose `index.html`
212
+ already links its favicon take the generated manifest without a duplicate tag. An empty array reads
213
+ as `false` (no `icons` member, rather than an empty one). `{ dir }` and the default are byte-identical
214
+ to what they always emitted.
215
+
216
+ One SVG at `sizes: 'any'` is a complete, installable icon set for a single-page app — and it is the
217
+ shape every non-scaffolded consumer already has. If you keep a hand-written `manifest.webmanifest`
218
+ only because the plugin could not name your icon, delete it: the generated manifest reproduces it
219
+ member for member, including the two hexes derived from `SURFACE.bg` rather than hand-copied, and it
220
+ takes the permanent `theme-allow-file` on that file with it.
221
+
198
222
  `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
223
+ `start_url` — pass `manifest: false` to skip it, or `icons: false` to skip the icons. Since 1.22.0
200
224
  `icons: false` omits BOTH the head `<link>` tags and the manifest's `icons` member; before that it
201
225
  skipped only the head, so a manifest shipped naming two PNGs the app never builds.
202
226
 
@@ -58,11 +58,13 @@ radial/matrix shape, is not optional — two `basalt` oxlint plugin rules
58
58
  compose `CartesianChart`. **Since 1.20.0 every assembly node is reported and waived on its own**
59
59
  — one comment no longer grants the whole file permanent immunity, which is how a 604-line chart
60
60
  file went unpoliced. To waive ONE node, put `theme-allow hand-rolled-plot — <why>` on it (or on a
61
- comment-only line above it). A genuinely non-single-plot shape (multi-pane, radial, matrix)
62
- declares the whole file with `theme-allow-file hand-rolled-plot — two panes over one x scale`,
63
- anywhere in it; `DualPanel` carries the repo's only one. **`theme-allow-file` is the 1.21.0
64
- spelling** — at 1.20.0 a node-scoped annotation was silently promoted to a file declaration, so
65
- per-node scoping was not expressible; move the one word. The file that DEFINES `CartesianChart` is
61
+ comment-only line above it). A genuinely non-single-plot shape (multi-pane, radial, matrix, or a
62
+ strip with no y dimension) declares the whole file with `theme-allow-file hand-rolled-plot —
63
+ <why>`, anywhere in it; basalt's own tree carries three, on `DualPanel`, `BandStrip` and
64
+ `MirroredBars`. `Donut` and `Heatmap` are non-single-plot too and carry none — they render no
65
+ assembly primitive, so the rule never fires and there is nothing to waive.
66
+ **`theme-allow-file` is the 1.21.0 spelling** — at 1.20.0 a node-scoped annotation was silently
67
+ promoted to a file declaration, so per-node scoping was not expressible; move the one word. The file that DEFINES `CartesianChart` is
66
68
  exempt definitionally (detected by declaration, not by path), since a rule saying "compose X"
67
69
  cannot fire inside X.
68
70
  - **`basalt/chart-legend-literal`** — a hand-written array literal passed to `ChartLegend`'s
@@ -160,6 +162,13 @@ ships no map kind.
160
162
  onto one domain value, silently dropping one from the plot (see #6 below for why that's the
161
163
  cursor's problem too). `Heatmap` is deliberately excluded: its existing `colLabel`/`rowLabel`
162
164
  already are that seam, and a second prop over one concern would fork them.
165
+ **Which x ticks get painted is a separate seam**, `xTickValues?: (keys, xMax) => readonly
166
+ string[]`, on `CartesianChart` and forwarded by `Bars`/`MultiLine`/`StackedArea`/`ZonedLine`
167
+ plus both band kinds. Resolution order is explicit VALUES → explicit COUNT (`xTicks`, unchanged)
168
+ → as many as fit (`smartTicks`); omit both and nothing moves. Reach for it on a dense time axis,
169
+ because **a count cannot express one**: `smartTicks`/`smartTicksEvery` append the final key
170
+ unconditionally, so any count that does not land exactly on the last index paints two labels on
171
+ top of each other at the right edge — at every count, not at an unlucky one.
163
172
  **The x axis is CATEGORICAL, and this is not inferable from the API.** `CartesianChart` builds
164
173
  its x scale as `scalePoint<string>`, so N points are N evenly spaced positions whatever the
165
174
  values behind the keys — there is no linear or time x scale. Correct and invisible for a domain
@@ -332,6 +341,24 @@ per-series `getValue` accessors on `ChartSeries<T>`):
332
341
  wrapper.
333
342
  - **`Donut`** — proportional donut with an optional center-content overlay. Composes `ChartFrame`
334
343
  directly (radial, not cartesian).
344
+ - **`BandStrip`** — 1-D categorical bands over a shared x axis, **no y dimension at all**: one rect
345
+ per slot, `getBand(d) => { state, fill?, absentFraction?, marker? }`. `series` IS the state set —
346
+ it drives the legend, each band's fill and the one derived tooltip row, so a strip cannot name a
347
+ state it does not draw. `absentFraction` paints the share of a folded slot nothing measured as a
348
+ hatch; `marker` carries a fact that must not be readable off the `fill` ramp. `cursorResolution`
349
+ defaults `'leading'` here (a band IS a bucket), not `'nearest'`. Composes `ChartFrame` directly —
350
+ `CartesianChart` renders `AxisLeftNumeric` unconditionally, so this shape could never use it.
351
+ - **`MirroredBars`** — two BAR panes over one x scale, mirrored around one baseline, each pane in
352
+ its own domain. `up`/`down` take `{ key, max?, autoMaxFloor?, ticks?, format }`; `upFraction`
353
+ (default 0.35) is the up pane's share of the band height, which is where the shared baseline sits;
354
+ `getAbsentFraction` hatches an unmeasured slot and `getBarOpacity` dims one. A sibling of
355
+ `DualPanel`, not an extension: `DualPanel`'s top pane is a LINE pane and its bottom takes one
356
+ SIGNED `getBar` over a symmetric domain. Two independent magnitudes are not one signed quantity.
357
+ Composes `ChartFrame` directly.
358
+
359
+ Both band kinds fold their domain by width (`fold: { minBandPx?, merge }`), and `foldBands` is
360
+ exported so a consumer can test their merge against the grouping that will actually run.
361
+ `HatchPattern` / `hatchFill` / `hatchSizeFor` ship the absence fill.
335
362
 
336
363
  `ZonedLine` and `MultiLine` also accept `xZones?: XZoneSpec[]` — the vertical counterpart to a
337
364
  kind's horizontal zone bands, for marking a time window rather than a value range. Each
@@ -477,6 +504,14 @@ margin. Passing `y2` to get a dual-axis chart no longer needs a manual margin nu
477
504
  The escape hatch is `margin={{ left: n }}` (`Partial<ChartMargin>`) on `CartesianChart` — a per-side
478
505
  override applied LAST, after measurement, so it always wins.
479
506
 
507
+ **A band plot has one extra law, because a band axis puts its FIRST tick on the plot's left edge.**
508
+ With no left axis to measure, `autoMargin` stops at `VX.margin.left` and half the first x label
509
+ hangs off the canvas — so `BandStrip` floors its left gutter at half the widest x label, mirroring
510
+ what `autoMargin` already does on the right. Both terminal gutters are then capped (14% of width
511
+ left, 12% right): uncapped, half a `DD.MM HH:MM` label is 48px of a 338px chart. `MirroredBars`
512
+ probes real tick labels from both panes, so it takes the plain measured margin and neither the
513
+ floor nor the cap applies.
514
+
480
515
  `chartMargin(opts?)` (`basalt-ui/tokens`, also re-exported from `basalt-ui/charts`) still exists,
481
516
  but only matters for a bespoke chart that composes `ChartFrame` directly (not `CartesianChart`) and
482
517
  needs a right-axis-sized margin without running its own measurement pass — `DualPanel` and `Heatmap`
@@ -96,6 +96,22 @@ An id is a guard kind (`raw-surface`, `inline-spacing`, …) or an oxlint plugin
96
96
  still ends the block; that is how you say "this comment is not about the next statement".
97
97
  - **`basalt/hand-rolled-plot` grants whole-file immunity only through `theme-allow-file`.** Every
98
98
  assembly node is otherwise waived on its own.
99
+ - **Eight shapes are asserted NOT to waive, in both halves.** They are pinned as unsupported rather
100
+ than merely left out, so "unsupported" and "silently broken" stop reading the same. Don't write
101
+ them:
102
+
103
+ | Shape | Why it does not waive |
104
+ | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
105
+ | a blank line between the annotation and the code — after `//`, after `{/* */}`, or after a `{/*` whose closer sits on its own line | a blank line is how you say "this comment is not about the next statement" |
106
+ | 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 |
107
+ | the token inside a string literal | same |
108
+ | above a multi-line OPENING tag, when the finding sits on a later attribute line | a waiver reaches the first line below its comment, not an arbitrary line further down — put it beside the attribute |
109
+
110
+ The whole grid — four axes, comment style × token position × where the closer falls × what
111
+ follows — is pinned row for row in `src/guard/check-source.test.ts` (37 supported + these 8) and
112
+ `configs/oxlint-plugin.test.ts` (32 + 8; five of the guard's rows are CSS/HTML/JSON dialects
113
+ oxlint never sees). Zero disagreements between the two, and no waiver tally moved in any of the
114
+ seven consumer repos.
99
115
 
100
116
  ### Audit them: `basalt-ui check-theme --audit-allows`
101
117
 
@@ -141,7 +157,7 @@ nothing is forked.
141
157
  - **Exact name match only** — the one limit that is structural. Round 5 confirmed the miss rate:
142
158
  linewatch's forks are named `Cell` and `Box`, rb's is `Stat`. Rename the fork and the rule goes
143
159
  quiet, which is what a fork's author naturally does.
144
- - **Scope: basalt consumers only, and component-shaped declarations only.** Since 1.21.1 the rule
160
+ - **Scope: basalt consumers only, and component-shaped declarations only.** Since 1.22.0 the rule
145
161
  gates on `isBasaltScopedFile` like every other rule in the file, and needs a function, an arrow, a
146
162
  `memo`/`forwardRef` wrapper or a class extending one. A plain data class sharing a name with a
147
163
  shipped export is a collision, not a fork — it fired on a `SlugTracker` class in a React-free
@@ -16,9 +16,9 @@ everything below assumes — composing `CartesianChart` (single-plot) or `ChartF
16
16
  Before writing chart code, decide which of three paths you are on:
17
17
 
18
18
  1. **Reuse a shipped kind.** basalt-ui ships `ZonedLine`, `Bars`, `StackedArea`, `MultiLine`,
19
- `DualPanel`, `Heatmap`, `Donut`. If the shape matches, use it — props are declarative (`data`,
20
- `chartId`, `getX`, `series`/`positiveBars`, one `AxisConfig` per axis, zones/thresholds/refLines
21
- as plain arrays). Done.
19
+ `DualPanel`, `Heatmap`, `Donut`, `BandStrip`, `MirroredBars`. If the shape matches, use it —
20
+ props are declarative (`data`, `chartId`, `getX`, `series`/`positiveBars`, one `AxisConfig` per
21
+ axis, zones/thresholds/refLines as plain arrays). Done.
22
22
  - **`ZonedLine`** — a single-series line with zone bands, thresholds, x-zones, refLines.
23
23
  - **`Bars`** — 1+ stacked positive/negative bar series, optional line overlays, dual-axis config.
24
24
  - **`StackedArea`** — opaque stacked bands, auto cumulative-top y-domain.
@@ -33,6 +33,13 @@ Before writing chart code, decide which of three paths you are on:
33
33
  - **`Heatmap`** — category×category intensity grid (`color-mix` alpha), per-cell tooltip,
34
34
  optional gradient legend strip. Replaces bespoke time-of-day.
35
35
  - **`Donut`** — proportional donut, optional `centerContent` overlay.
36
+ - **`BandStrip`** — 1-D categorical bands, no y dimension: one rect per slot,
37
+ `getBand(d) => { state, fill?, absentFraction?, marker? }`. `series` is the state set and
38
+ drives legend, fill and the one derived tooltip row. Replaces a bespoke state/availability
39
+ strip.
40
+ - **`MirroredBars`** — two BAR panes over one x scale and one baseline, each in its own domain
41
+ (`up`/`down` take `{ key, max?, autoMaxFloor?, ticks?, format }`). Not `DualPanel`, whose top
42
+ pane is a LINE and whose bottom takes one SIGNED accessor.
36
43
  2. **Extract a new kind (second instance of a pattern).** When you are about to build the _second_
37
44
  chart of a shape that has no shipped kind, extract a kind. Don't extract on the first; don't
38
45
  wait past the third. A new kind lives in the consumer's chart kinds dir (or, if generic enough
@@ -43,6 +50,12 @@ Before writing chart code, decide which of three paths you are on:
43
50
  genuinely different scales that don't fit any kind's config surface) composes `CartesianChart`
44
51
  or `ChartFrame` directly in the page's chart file — not in `kinds/`.
45
52
 
53
+ **Extracting a kind? Prove it by porting.** A kind is proven by moving a real consumer's call
54
+ sites onto it and reporting what it could NOT express — not by a demo page, which is written
55
+ against the API that already exists. The two band kinds were built that way (linewatch, 1884 → 957
56
+ source lines, 11 `hand-rolled-plot` waivers retired, two live `NaN`-geometry bugs found). Ship the
57
+ "still cannot express" list with the kind.
58
+
46
59
  > Anti-pattern: a single `<Chart type="..." config={...} />` god-component that switches by kind.
47
60
  > That is the Recharts trap. Prefer N small kinds. **If a chart doesn't fit the primitives, add a
48
61
  > kind — don't loosen the primitives.**
@@ -109,8 +122,9 @@ Theme tuning of these series happens in the theme lab — pass them as `groups`
109
122
  ## Compose the primitives (never hand-roll)
110
123
 
111
124
  Every chart — kind or bespoke — composes `CartesianChart` for a single plot rect, or `ChartFrame`
112
- directly for a multi-pane / non-cartesian shape (`DualPanel`'s two panes, `Heatmap`'s grid,
113
- `Donut`'s ring). Either way the contract is the same: the primitive owns margin, scales, grid,
125
+ directly for a multi-pane / non-cartesian shape — five of them ship: `DualPanel`'s two panes,
126
+ `MirroredBars`' two bar panes, `BandStrip`'s missing y dimension, `Heatmap`'s grid, `Donut`'s ring.
127
+ Either way the contract is the same: the primitive owns margin, scales, grid,
114
128
  axes, legend, the shared cursor, and the tooltip; you supply `series` and draw ONLY marks. This is
115
129
  mandatory, not a convention — `basalt/hand-rolled-plot` fails the build on a bypass (see "Respect
116
130
  the boundary" below); it is easier to compose it than to work around it.
@@ -212,9 +226,10 @@ import { ChartCursorScope } from 'basalt-ui/charts'
212
226
  primitive (`AxisLeftNumeric`, `AxisRightNumeric`, `AxisBottomDate`, `HoverOverlay`, `Crosshair`)
213
227
  rendered in a file that doesn't compose `CartesianChart`, per NODE. Escape:
214
228
  `theme-allow hand-rolled-plot — <why>` on the one node, or `theme-allow-file hand-rolled-plot —
215
- <why>` anywhere in the file — how a genuinely non-single-plot shape (`DualPanel`, `Heatmap`,
216
- `Donut`) declares itself. `-file` is the 1.21.0 spelling and it is required. The file that defines
217
- `CartesianChart` is exempt definitionally.
229
+ <why>` anywhere in the file — how a genuinely non-single-plot shape (`DualPanel`, `MirroredBars`,
230
+ `BandStrip`; `Heatmap` and `Donut` render no assembly primitive and need none) declares itself.
231
+ `-file` is the 1.21.0 spelling and it is required. The file that defines `CartesianChart` is
232
+ exempt definitionally.
218
233
  - **No hand-authored legends.** `basalt/chart-legend-literal` fails the build on a hand-written
219
234
  array literal passed to `ChartLegend`'s `items` — derive it from the same `series` the chart draws
220
235
  (`deriveLegend`, or let `ChartFrame`/`CartesianChart` do it) so it can't name a series the plot no
@@ -1,6 +1,8 @@
1
1
  # {{APP_NAME}} — Design
2
2
 
3
- > Managed by basalt-ui ({{BASALT_VERSION}}). This is a **thin** instantiation — it records this
3
+ > Managed by basalt-ui — run `basalt-ui doctor` for the version, never trust a number written into
4
+ > a file (this one is a seed: written once at `init`, then owned by you, so any version stamped
5
+ > here would be frozen at scaffold time). This is a **thin** instantiation — it records this
4
6
  > app's **deltas only** on top of the shipped `basalt-*` rules. The universal law (earned color,
5
7
  > neutral-by-default, three-tier `--vx-*` tokens, theme-is-data, the chart primitive contract, the
6
8
  > elevation/density/shape doctrine) lives in those rules and the `/basalt:design` skill, and is