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
@@ -14,8 +14,10 @@ primitives so we can build exactly the chart we want; the trade-off is that ever
14
14
  structure unless we compose shared building blocks. **`CartesianChart` plus a small kind set are
15
15
  the contract, not optional polish** — composing `CartesianChart` (single-plot) or `ChartFrame`
16
16
  directly (multi-pane/radial/matrix) is mandatory for every chart, mechanically enforced by
17
- `basalt/hand-rolled-plot` (see "Mechanical enforcement" below). `docs/CHARTS-SPEC.md` is the ground
18
- truth this doc reflects.
17
+ `basalt/hand-rolled-plot` (see "Mechanical enforcement" below). **This file is the contract you
18
+ receive** — the basalt-ui repo's `docs/CHARTS-SPEC.md` is the same argument at length, and it does
19
+ not ship in the package. Read it at
20
+ <https://github.com/jkrumm/basalt-ui/blob/master/docs/CHARTS-SPEC.md> or not at all.
19
21
 
20
22
  ## The boundary (lint-enforced)
21
23
 
@@ -58,11 +60,13 @@ radial/matrix shape, is not optional — two `basalt` oxlint plugin rules
58
60
  compose `CartesianChart`. **Since 1.20.0 every assembly node is reported and waived on its own**
59
61
  — one comment no longer grants the whole file permanent immunity, which is how a 604-line chart
60
62
  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
63
+ comment-only line above it). A genuinely non-single-plot shape (multi-pane, radial, matrix, or a
64
+ strip with no y dimension) declares the whole file with `theme-allow-file hand-rolled-plot —
65
+ <why>`, anywhere in it; basalt's own tree carries three, on `DualPanel`, `BandStrip` and
66
+ `MirroredBars`. `Donut` and `Heatmap` are non-single-plot too and carry none — they render no
67
+ assembly primitive, so the rule never fires and there is nothing to waive.
68
+ **`theme-allow-file` is the 1.21.0 spelling** — at 1.20.0 a node-scoped annotation was silently
69
+ promoted to a file declaration, so per-node scoping was not expressible; move the one word. The file that DEFINES `CartesianChart` is
66
70
  exempt definitionally (detected by declaration, not by path), since a rule saying "compose X"
67
71
  cannot fire inside X.
68
72
  - **`basalt/chart-legend-literal`** — a hand-written array literal passed to `ChartLegend`'s
@@ -160,6 +164,13 @@ ships no map kind.
160
164
  onto one domain value, silently dropping one from the plot (see #6 below for why that's the
161
165
  cursor's problem too). `Heatmap` is deliberately excluded: its existing `colLabel`/`rowLabel`
162
166
  already are that seam, and a second prop over one concern would fork them.
167
+ **Which x ticks get painted is a separate seam**, `xTickValues?: (keys, xMax) => readonly
168
+ string[]`, on `CartesianChart` and forwarded by `Bars`/`MultiLine`/`StackedArea`/`ZonedLine`
169
+ plus both band kinds. Resolution order is explicit VALUES → explicit COUNT (`xTicks`, unchanged)
170
+ → as many as fit (`smartTicks`); omit both and nothing moves. Reach for it on a dense time axis,
171
+ because **a count cannot express one**: `smartTicks`/`smartTicksEvery` append the final key
172
+ unconditionally, so any count that does not land exactly on the last index paints two labels on
173
+ top of each other at the right edge — at every count, not at an unlucky one.
163
174
  **The x axis is CATEGORICAL, and this is not inferable from the API.** `CartesianChart` builds
164
175
  its x scale as `scalePoint<string>`, so N points are N evenly spaced positions whatever the
165
176
  values behind the keys — there is no linear or time x scale. Correct and invisible for a domain
@@ -332,6 +343,49 @@ per-series `getValue` accessors on `ChartSeries<T>`):
332
343
  wrapper.
333
344
  - **`Donut`** — proportional donut with an optional center-content overlay. Composes `ChartFrame`
334
345
  directly (radial, not cartesian).
346
+ - **`BandStrip`** — 1-D categorical bands over a shared x axis, **no y dimension at all**: one rect
347
+ per slot, `getBand(d) => { state, fill?, absentFraction?, marker? }`. `series` IS the state set —
348
+ it drives the legend, each band's fill and the one derived tooltip row, so a strip cannot name a
349
+ state it does not draw. `absentFraction` paints the share of a folded slot nothing measured as a
350
+ hatch; `marker` carries a fact that must not be readable off the `fill` ramp. `cursorResolution`
351
+ defaults `'leading'` here (a band IS a bucket), not `'nearest'`. Composes `ChartFrame` directly —
352
+ `CartesianChart` renders `AxisLeftNumeric` unconditionally, so this shape could never use it.
353
+ `BandStripSeries.formatValue` is `(d) => string | null` — `null` renders an em dash, an absent
354
+ READING, which `''` cannot say (`''` is a state whose label is the whole row).
355
+ - **`MirroredBars`** — two BAR panes over one x scale, mirrored around one baseline, each pane in
356
+ its own domain. `up`/`down` take `{ key, max?, autoMaxFloor?, ticks?, format }`; `upFraction`
357
+ (default 0.35) is the up pane's share of the band height, which is where the shared baseline sits;
358
+ `getAbsentFraction` hatches an unmeasured slot and `getBarOpacity` dims one. A sibling of
359
+ `DualPanel`, not an extension: `DualPanel`'s top pane is a LINE pane and its bottom takes one
360
+ SIGNED `getBar` over a symmetric domain. Two independent magnitudes are not one signed quantity.
361
+ Composes `ChartFrame` directly.
362
+
363
+ Both band kinds fold their domain by width (`fold: { minBandPx?, merge }`), and `foldBands` is
364
+ exported so a consumer can test their merge against the grouping that will actually run.
365
+ `HatchPattern` / `hatchFill` / `hatchSizeFor` ship the absence fill.
366
+
367
+ **`merge` does not run on a sub-cap dataset.** `foldBands` returns `[...data]` unchanged when
368
+ `data.length <= cap`, which on a wide viewport is the common case — so any bookkeeping the merge
369
+ adds (how many source buckets a slot folded, how many were unmeasured) is absent exactly where you
370
+ are least likely to look for it. Put that bookkeeping on the datum at CONSTRUCTION and have `merge`
371
+ sum it; do not compute it from `group.length` inside the merge. Summing is also the composable
372
+ form, and it keeps "every source bucket accounted for exactly once" true by construction.
373
+
374
+ **A band state naming no `series` entry throws in dev.** `series` IS the state set, so an
375
+ unresolvable `state` has no legend entry, no colour and no tooltip row — and the old behaviour,
376
+ skipping the band, drew a coverage GAP, which on a measured/not-measured strip is a false claim
377
+ about the data rather than an "unknown". Where the key comes from decides the treatment:
378
+
379
+ | Key comes off | Dev | Production |
380
+ | ------------------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------- |
381
+ | the DATUM — `BandSpan.state`, `marker.state` | throws, naming the key and the valid set | dashed neutral outline band + an `Unknown state` / `<key>` tooltip row |
382
+ | a PROP — `absentState`, `MirroredBars`' `up.key` / `down.key` | throws | throws |
383
+
384
+ A feed that grows a state basalt has never seen must degrade rather than take a dashboard down; a
385
+ typo, which is the same input, still fails loudly wherever it is written. The dashed neutral outline
386
+ is used by no legend entry and no state fill, so it can never be mistaken for data. Pane keys are
387
+ props, never data-driven — an unresolvable `up.key` used to hide the pane AND its axis, which reads
388
+ as "that half measured zero".
335
389
 
336
390
  `ZonedLine` and `MultiLine` also accept `xZones?: XZoneSpec[]` — the vertical counterpart to a
337
391
  kind's horizontal zone bands, for marking a time window rather than a value range. Each
@@ -477,6 +531,23 @@ margin. Passing `y2` to get a dual-axis chart no longer needs a manual margin nu
477
531
  The escape hatch is `margin={{ left: n }}` (`Partial<ChartMargin>`) on `CartesianChart` — a per-side
478
532
  override applied LAST, after measurement, so it always wins.
479
533
 
534
+ **A band plot has one extra law, because a band axis puts its FIRST tick on the plot's left edge.**
535
+ With no left axis to measure, `autoMargin` stops at `VX.margin.left` and half the first x label
536
+ hangs off the canvas — so `BandStrip` floors its left gutter at half the widest x label, mirroring
537
+ what `autoMargin` already does on the right. Both terminal gutters are then capped (14% of width
538
+ left, 12% right): uncapped, half a `DD.MM HH:MM` label is 48px of a 338px chart. `MirroredBars`
539
+ probes real tick labels from both panes, so it takes the plain measured margin and neither the
540
+ floor nor the cap applies.
541
+
542
+ **The height law is the number you actually have to compute.** A band row is
543
+ `height − legend − margin.top − margin.bottom`: `ChartFrame` reserves the measured legend band out
544
+ of the plot rect first, then the kind takes the margins out of what is left. There is no
545
+ `bandHeight` prop — to get a 44px band, hand the frame `44 + margins + legend`. `margin={{ top: 0 }}`
546
+ reclaims the whole top gutter, which a 1-D strip draws nothing in; the bottom is not reclaimable,
547
+ `autoMargin` floors it at `VX.margin.bottom`. Derive the constants from `VX.margin` rather than
548
+ restating them — a consumer that copies `30` into three chart files has three silent-drift sites the
549
+ moment the density scale is retuned.
550
+
480
551
  `chartMargin(opts?)` (`basalt-ui/tokens`, also re-exported from `basalt-ui/charts`) still exists,
481
552
  but only matters for a bespoke chart that composes `ChartFrame` directly (not `CartesianChart`) and
482
553
  needs a right-axis-sized margin without running its own measurement pass — `DualPanel` and `Heatmap`
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  source: basalt-ui
3
- description: Content/prose surface from basalt-ui/content — Prose, CodeBlock, Callout, TableOfContents, ReadingProgress, Markdown (streaming-aware), MermaidDiagram, MDX component map, ArticleLayout, ArticleCard/Grid, GuideLink/GuideDrawer, and the content-collections + TanStack Start recipe. Mantine-coupled (docs/CONTENT-SPEC.md in the basalt-ui repo).
3
+ description: Content/prose surface from basalt-ui/content — Prose, CodeBlock, Callout, TableOfContents, ReadingProgress, Markdown (streaming-aware), MermaidDiagram, MDX component map, ArticleLayout, ArticleCard/Grid, GuideLink/GuideDrawer, and the content-collections + TanStack Start recipe. Mantine-coupled.
4
4
  paths:
5
5
  - 'src/content/**'
6
6
  - 'apps/**/src/**/*content*'
@@ -253,7 +253,7 @@ step 3).
253
253
  surface (`basalt-ui/commands`) — the same tier as `toRouteActions`. It joins title, description,
254
254
  category, tags, and slug into a `keywords` string for substring search; it does not tokenize, rank,
255
255
  index, or persist. An app that outgrows substring matching over a client-side array brings its own
256
- search index — see docs/CONTENT-SPEC.md §9 (Non-goals) in the basalt-ui repo.
256
+ search index — see <https://github.com/jkrumm/basalt-ui/blob/master/docs/CONTENT-SPEC.md> §9 (Non-goals), which is not in the package.
257
257
 
258
258
  `ArticleGrid` stays a dumb layout wrapper — deliberate, so callers keep composition (sorting,
259
259
  filtering, and pagination all happen above it, not inside it).
@@ -406,4 +406,4 @@ const articleActions = toRouteActions(
406
406
 
407
407
  KaTeX/math, full mermaid grammar bundling (d2, pintora), Vega-Lite streamed specs, versioned docs/
408
408
  i18n routing, and a `create-basalt-docs` scaffold are all out of scope for 1.0 — see
409
- `docs/CONTENT-SPEC.md` §9 in the basalt-ui repo for the full non-goals list and rationale.
409
+ <https://github.com/jkrumm/basalt-ui/blob/master/docs/CONTENT-SPEC.md> §9 for the full non-goals list and rationale (not in the package).
@@ -30,15 +30,15 @@ renders.
30
30
 
31
31
  - Use `createBasaltTheme(overrides?)` for the theme — it merges your overrides over `baseTheme`
32
32
  (last-wins), so you can retune any field without forking. Don't hand-build `createTheme`.
33
- - The theme reskins **every** Mantine accent to the modern-zinc identity (`docs/DESIGN-SPEC.md` in
34
- the basalt-ui repo) —
33
+ - The theme reskins **every** Mantine accent to the modern-zinc identity (long form:
34
+ <https://github.com/jkrumm/basalt-ui/blob/master/docs/DESIGN-SPEC.md>, not shipped in the package) —
35
35
  **cool-neutral zinc** ramps on both light and dark — plus a **single saturated sky-blue accent**
36
36
  — split by role: as INK (links, active-nav icon, chart lines, focus ring) `#0077bd` light /
37
37
  `#8ec5ff` dark; as a FILLED SURFACE `#0077bd` in BOTH schemes with a white label — and binds
38
38
  Mantine's surface vars AND its color families to the same `--vx-*` tokens
39
39
  the charts use (`cssVariablesResolver`, pre-wired in `BasaltProvider`), so chrome and charts share
40
40
  one scheme-reactive identity. (Blueprint / Basalt zinc-charcoal are the historical hue-tuning
41
- ancestors; `docs/DESIGN-SPEC.md` in the basalt-ui repo supersedes both.) Don't add a second
41
+ ancestors; DESIGN-SPEC supersedes both.) Don't add a second
42
42
  `cssVariablesResolver`.
43
43
  - Color scheme: read/write via `useMantineColorScheme()`. **Never** read the color scheme from
44
44
  localStorage directly, and never `localStorage.getItem('theme')` — see basalt-state.md.
@@ -220,7 +220,7 @@ shape doctrine lives here; the spacing/radius/type **tokens** are in basalt-toke
220
220
  Mechanically enforced by `basalt-ui check-theme`'s `raw-surface` guard. Outer spacing comes from the
221
221
  parent `Stack`/`SimpleGrid` gap, not an intrinsic card margin.
222
222
  - **Depth = a whisper shadow with a 1px ring baked in, not a plain hairline** (see
223
- `docs/DESIGN-SPEC.md` in the basalt-ui repo, doctrine inversion #1 — this supersedes the old
223
+ <https://github.com/jkrumm/basalt-ui/blob/master/docs/DESIGN-SPEC.md>, doctrine inversion #1 — this supersedes the old
224
224
  "never a drop shadow" rule). **Three depth tiers, split control-vs-panel-vs-floating:**
225
225
  - **`shadow-card` — panels.** A surface that HOLDS content: `Card`, `Paper`, `Notification`,
226
226
  `ChartCard`, `SettingsSection`. Its light-mode ring is an **outset** 1px hairline; dark is a
@@ -10,7 +10,8 @@ paths:
10
10
  # Basalt Tokens — Color, Spacing, Radius, Type
11
11
 
12
12
  basalt-ui ships one color/type/spacing/radius identity shared by the Mantine chrome and the visx
13
- charts. The **identity is modern zinc** (see `docs/DESIGN-SPEC.md` in the basalt-ui repo) —
13
+ charts. The **identity is modern zinc** (long form: <https://github.com/jkrumm/basalt-ui/blob/master/docs/DESIGN-SPEC.md>, which does not ship in the
14
+ package) —
14
15
  **cool-neutral zinc surfaces**
15
16
  (Tailwind zinc family) on both light and dark, low-contrast panel lift on a slightly darker page,
16
17
  depth via a whisper shadow + 1px ring (`shadow-card` on panels, `shadow-raised` on interactive
@@ -21,8 +22,7 @@ lines, focus ring) it is `#0077bd` light / `#8ec5ff` dark; as a FILLED SURFACE i
21
22
  `#0077bd` in BOTH schemes with a WHITE label (`--vx-accent-fill`, never `--vx-accent`). Neutrals do ~90% of the
22
23
  surface (60/30/10, pushed toward 90/10); the accent only points — primary CTA, focus, links, small
23
24
  status pops — never floods. (Blueprint/Basalt zinc-charcoal are the historical hue-tuning
24
- ancestors; `docs/DESIGN-SPEC.md` in the basalt-ui repo supersedes both — see its "Doctrine
25
- inversions" section.) This
25
+ ancestors; DESIGN-SPEC supersedes both — see its "Doctrine inversions" section.) This
26
26
  rule is the operational checklist; it is enforced mechanically by **`basalt-ui check-theme`**
27
27
  (wire it into `lint`: `oxlint . && basalt-ui check-theme`; `init` seeds that as `lint:basalt`). A
28
28
  violation fails the build. Escape hatch: a scoped `theme-allow <rule-id> — <reason>` comment
@@ -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
 
@@ -104,13 +120,26 @@ the guard with that one occurrence neutralized, so the verdict comes from the sc
104
120
  parsing the waiver's text:
105
121
 
106
122
  ```text
123
+ theme-allow annotations (3):
107
124
  src/shell/x.tsx:42 suppresses raw-surface@43
108
125
  src/theme/y.ts:18 SUPPRESSES NOTHING — dead, delete it
109
- src/charts/z.tsx:7 scoped to hand-rolled-plot — not a check-theme kind, so this audit cannot judge it
126
+ src/charts/z.tsx:7 suppresses hand-rolled-plot@8 (oxlint)
127
+
128
+ basalt.exemptRules entries (1):
110
129
  raw-hex: "site.webmanifest" suppresses findings in public/site.webmanifest — <the recorded reason>
130
+
131
+ 3 live, 1 dead, 0 unjudgeable, 0 unaccountable (reported as theme-allow-unscoped by a normal run).
132
+ Scope: the N file(s) check-theme scans — everything under basalt.roots (…), plus each root's
133
+ sibling index.html and public/ tree, plus anything named in basalt.include (none). …
111
134
  ```
112
135
 
113
- It closes with a `live / dead / outside reach / unaccountable` tally and **exits 1 on a dead
136
+ **Two sections, not one list** — annotations first, then `basalt.exemptRules`, each with its own
137
+ count. A plugin-rule annotation is judged too (since 1.22.0, by re-running oxlint over one
138
+ neutralized sibling file), so `unjudgeable` now means only "names no rule at all" or "oxlint could
139
+ not be run here" — never "not a check-theme kind". The scope line is part of the report: `0 dead`
140
+ is not `0 dead anywhere`.
141
+
142
+ It closes with a `live / dead / unjudgeable / unaccountable` tally and **exits 1 on a dead
114
143
  waiver**, so it wires into CI. A waiver that stopped covering anything is not harmless: it silently
115
144
  covers the next real finding on that line. Basalt's own source had one; it is deleted.
116
145
 
@@ -141,7 +170,7 @@ nothing is forked.
141
170
  - **Exact name match only** — the one limit that is structural. Round 5 confirmed the miss rate:
142
171
  linewatch's forks are named `Cell` and `Box`, rb's is `Stat`. Rename the fork and the rule goes
143
172
  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
173
+ - **Scope: basalt consumers only, and component-shaped declarations only.** Since 1.22.0 the rule
145
174
  gates on `isBasaltScopedFile` like every other rule in the file, and needs a function, an arrow, a
146
175
  `memo`/`forwardRef` wrapper or a class extending one. A plain data class sharing a name with a
147
176
  shipped export is a collision, not a fork — it fired on a `SlugTracker` class in a React-free
@@ -6,18 +6,26 @@ when_to_use: User is adding basalt-ui to a new or existing app, asks how to set
6
6
 
7
7
  `/basalt-app` scaffolds a basalt-ui consumer and keeps its shipped doctrine fresh. It wraps the two
8
8
  CLI subcommands — `basalt-ui init` (first run) and `basalt-ui sync` (refresh) — and explains the
9
- wiring they don't do for you (provider, shell, vite). All of it via `bunx basalt-ui` (Bun runtime).
9
+ wiring they don't do for you (provider, shell, vite). Bun runtime.
10
10
 
11
11
  ## One package, one command, one version
12
12
 
13
13
  Everything ships in the `basalt-ui` npm package — components, toolchain presets, rules, skills,
14
14
  templates. Becoming a consumer is exactly two steps:
15
15
 
16
- ```
16
+ ```bash
17
17
  bun add basalt-ui
18
- bunx basalt-ui init
18
+ bunx basalt-ui init # `init` is the ONE command that legitimately predates the install
19
+ ./node_modules/.bin/basalt-ui --version # every command after it: the LOCAL bin
19
20
  ```
20
21
 
22
+ **After `init`, never `bunx`.** `bunx` does not re-resolve a package it has cached, so it can run
23
+ a months-old CLI against a freshly-pinned install and report a green gate you did not configure —
24
+ which is exactly how one consumer filed a P0 against a 1.20.0 cache while believing it was on
25
+ 1.22.0. Use `./node_modules/.bin/basalt-ui` (or a `package.json` script, which `bun run` resolves
26
+ from the same place). `basalt-ui --version` / `-v` prints one bare line and exits 0; that is the
27
+ check, and it costs nothing to run first.
28
+
21
29
  There is no plugin and no marketplace. `init`/`sync` place every file with one of two modes, and
22
30
  which mode a file gets answers a single question — **does Claude read it?**
23
31
 
@@ -130,7 +138,7 @@ The consumer series file (`<first basalt.root>/lib/series.ts` by default — so
130
138
  a plain app, `apps/web/src/lib/series.ts` on a monorepo; override via `basalt.seriesModulePath`) is
131
139
  the single guard-exempt palette source — see `/basalt-charts`.
132
140
 
133
- ## Refresh: `bunx basalt-ui sync`
141
+ ## Refresh: `basalt-ui sync`
134
142
 
135
143
  `sync` re-applies shipped doctrine after a basalt-ui upgrade, three-way against
136
144
  `.basalt/manifest.json`:
@@ -158,14 +166,15 @@ both.
158
166
  ## Checklist
159
167
 
160
168
  - Ran `bun add basalt-ui && bunx basalt-ui init` — rules, skills, CLAUDE block, DESIGN.md all
161
- present from the one package.
169
+ present from the one package. Every command after it runs the local bin, not `bunx`.
162
170
  - `BasaltProvider` wraps the app; `createBasaltTheme` used for theme deltas; `basalt-ui/styles.css`
163
171
  imported.
164
172
  - `basaltViteConfig` adopted; `oxlint . && basalt-ui check-theme` is the lint command.
165
- - `basaltAppPlugin` composed into `plugins` (see the vite.config.ts snippet above); icon files
166
- (`favicon.ico`, `favicon.svg`, `favicon-96x96.png`, `apple-touch-icon.png`,
167
- `web-app-manifest-192x192.png`, `web-app-manifest-512x512.png`) generated and placed under
168
- `public/` — `basalt-ui doctor` warns if any are missing.
173
+ - `basaltAppPlugin` composed into `plugins` (see the vite.config.ts snippet above); either the six
174
+ default icon files (`favicon.ico`, `favicon.svg`, `favicon-96x96.png`, `apple-touch-icon.png`,
175
+ `web-app-manifest-192x192.png`, `web-app-manifest-512x512.png`) under `public/`, or an `icons`
176
+ array naming the ones you actually have. `doctor` reads the option out of your vite config, so an
177
+ app that names one SVG is not told to generate five PNGs it does not want.
169
178
  - `site.webmanifest` is emitted by `basaltAppPlugin` — no manual manifest file needed unless
170
179
  `manifest: false` was passed.
171
180
  - A thin `DESIGN.md` (deltas) + the thirteen `basalt-*` rules + the managed CLAUDE block are present.
@@ -179,14 +188,18 @@ both.
179
188
  - `init`, `sync`, and `check-theme` are all **real**. `sync` reconciles via a sha256 three-way diff
180
189
  (`--check` is a CI drift gate, `--force` overwrites local edits); `check-theme` is the palette
181
190
  guard; `doctor` checks resolution, version, guard scan, oxlint preset, spacing-scale drift and
182
- cross-package `ai` major parity. All via `bunx basalt-ui …`.
183
- - **Not a React app?** `bunx basalt-ui tokens:css --out src/tokens.css` emits the `--vx-*` layer with
191
+ cross-package `ai` major parity. All via the local bin. An unrecognized flag exits 1 naming it
192
+ (`doctor --json` used to run doctor and exit 0), and an unknown command says so above the usage
193
+ block.
194
+ - **Not a React app?** `bunx basalt-ui tokens:css --out src/tokens.css` (no install to resolve, so
195
+ `bunx` is right here) emits the `--vx-*` layer with
184
196
  no package in your dependency tree, `--selector-class dark` for the Tailwind `<html class="dark">`
185
197
  convention, and `fonts:css` emits the shipped `--basalt-font-*` stacks. Both carry the two-line
186
198
  `@generated basalt-ui` header the guard skips on — that header verbatim, and then only the LINES
187
199
  that are basalt custom properties, selectors, `}` or self-closing comments, so the marker pasted
188
200
  into a `.tsx` suppresses nothing and a declaration added to such a file is still reported — and
189
- both take `--check` as a CI drift gate. See `docs/FRAMEWORK-FREE.md` in the basalt-ui repo.
201
+ both take `--check` as a CI drift gate — which compares everything but the header's provenance
202
+ line, so a basalt-ui version bump alone never forces a no-op commit. See <https://github.com/jkrumm/basalt-ui/blob/master/docs/FRAMEWORK-FREE.md> (not in the package).
190
203
  - The framework ships **no** icon or notification dep — pass icons as `ReactNode` and wire toasts
191
204
  yourself (e.g. `ThemeLabControls`' `copyIcon` / `onCopy`).
192
205
  - The framework DOES ship `motion` (motion.dev, formerly framer-motion) as a bundled dependency —
@@ -7,8 +7,10 @@ when_to_use: User wants to add a chart, restyle an existing one structurally, in
7
7
  `/basalt-charts` is the how-to for the chart system. `/basalt-design` owns the aesthetic law
8
8
  (palette, restraint, the theme-lab loop) and the DESIGN.md precedence — read it for "what color /
9
9
  how should this look". This skill answers "how do I wire it" with basalt-ui's `./charts` and
10
- `./tokens` surfaces. `docs/CHARTS-SPEC.md` is the ground truth for the chart-primitive contract
11
- everything below assumes — composing `CartesianChart` (single-plot) or `ChartFrame` directly
10
+ `./tokens` surfaces. `.claude/rules/basalt-charts.md` — which you have — is the chart-primitive
11
+ contract everything below assumes (the repo's `docs/CHARTS-SPEC.md` is the long form and is not in
12
+ the package:
13
+ <https://github.com/jkrumm/basalt-ui/blob/master/docs/CHARTS-SPEC.md>) — composing `CartesianChart` (single-plot) or `ChartFrame` directly
12
14
  (multi-pane/radial/matrix) is mandatory, mechanically enforced (see "Respect the boundary" below).
13
15
 
14
16
  ## The decision: kind vs bespoke (Rule of Three)
@@ -16,9 +18,9 @@ everything below assumes — composing `CartesianChart` (single-plot) or `ChartF
16
18
  Before writing chart code, decide which of three paths you are on:
17
19
 
18
20
  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.
21
+ `DualPanel`, `Heatmap`, `Donut`, `BandStrip`, `MirroredBars`. If the shape matches, use it —
22
+ props are declarative (`data`, `chartId`, `getX`, `series`/`positiveBars`, one `AxisConfig` per
23
+ axis, zones/thresholds/refLines as plain arrays). Done.
22
24
  - **`ZonedLine`** — a single-series line with zone bands, thresholds, x-zones, refLines.
23
25
  - **`Bars`** — 1+ stacked positive/negative bar series, optional line overlays, dual-axis config.
24
26
  - **`StackedArea`** — opaque stacked bands, auto cumulative-top y-domain.
@@ -33,6 +35,13 @@ Before writing chart code, decide which of three paths you are on:
33
35
  - **`Heatmap`** — category×category intensity grid (`color-mix` alpha), per-cell tooltip,
34
36
  optional gradient legend strip. Replaces bespoke time-of-day.
35
37
  - **`Donut`** — proportional donut, optional `centerContent` overlay.
38
+ - **`BandStrip`** — 1-D categorical bands, no y dimension: one rect per slot,
39
+ `getBand(d) => { state, fill?, absentFraction?, marker? }`. `series` is the state set and
40
+ drives legend, fill and the one derived tooltip row. Replaces a bespoke state/availability
41
+ strip.
42
+ - **`MirroredBars`** — two BAR panes over one x scale and one baseline, each in its own domain
43
+ (`up`/`down` take `{ key, max?, autoMaxFloor?, ticks?, format }`). Not `DualPanel`, whose top
44
+ pane is a LINE and whose bottom takes one SIGNED accessor.
36
45
  2. **Extract a new kind (second instance of a pattern).** When you are about to build the _second_
37
46
  chart of a shape that has no shipped kind, extract a kind. Don't extract on the first; don't
38
47
  wait past the third. A new kind lives in the consumer's chart kinds dir (or, if generic enough
@@ -43,6 +52,12 @@ Before writing chart code, decide which of three paths you are on:
43
52
  genuinely different scales that don't fit any kind's config surface) composes `CartesianChart`
44
53
  or `ChartFrame` directly in the page's chart file — not in `kinds/`.
45
54
 
55
+ **Extracting a kind? Prove it by porting.** A kind is proven by moving a real consumer's call
56
+ sites onto it and reporting what it could NOT express — not by a demo page, which is written
57
+ against the API that already exists. The two band kinds were built that way (linewatch, 1884 → 957
58
+ source lines, 11 `hand-rolled-plot` waivers retired, two live `NaN`-geometry bugs found). Ship the
59
+ "still cannot express" list with the kind.
60
+
46
61
  > Anti-pattern: a single `<Chart type="..." config={...} />` god-component that switches by kind.
47
62
  > That is the Recharts trap. Prefer N small kinds. **If a chart doesn't fit the primitives, add a
48
63
  > kind — don't loosen the primitives.**
@@ -109,8 +124,9 @@ Theme tuning of these series happens in the theme lab — pass them as `groups`
109
124
  ## Compose the primitives (never hand-roll)
110
125
 
111
126
  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,
127
+ directly for a multi-pane / non-cartesian shape — five of them ship: `DualPanel`'s two panes,
128
+ `MirroredBars`' two bar panes, `BandStrip`'s missing y dimension, `Heatmap`'s grid, `Donut`'s ring.
129
+ Either way the contract is the same: the primitive owns margin, scales, grid,
114
130
  axes, legend, the shared cursor, and the tooltip; you supply `series` and draw ONLY marks. This is
115
131
  mandatory, not a convention — `basalt/hand-rolled-plot` fails the build on a bypass (see "Respect
116
132
  the boundary" below); it is easier to compose it than to work around it.
@@ -212,9 +228,10 @@ import { ChartCursorScope } from 'basalt-ui/charts'
212
228
  primitive (`AxisLeftNumeric`, `AxisRightNumeric`, `AxisBottomDate`, `HoverOverlay`, `Crosshair`)
213
229
  rendered in a file that doesn't compose `CartesianChart`, per NODE. Escape:
214
230
  `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.
231
+ <why>` anywhere in the file — how a genuinely non-single-plot shape (`DualPanel`, `MirroredBars`,
232
+ `BandStrip`; `Heatmap` and `Donut` render no assembly primitive and need none) declares itself.
233
+ `-file` is the 1.21.0 spelling and it is required. The file that defines `CartesianChart` is
234
+ exempt definitionally.
218
235
  - **No hand-authored legends.** `basalt/chart-legend-literal` fails the build on a hand-written
219
236
  array literal passed to `ChartLegend`'s `items` — derive it from the same `series` the chart draws
220
237
  (`deriveLegend`, or let `ChartFrame`/`CartesianChart` do it) so it can't name a series the plot no
@@ -12,8 +12,9 @@ Colors come from the three-tier `--vx-*` token system — read `VX.*` / a `defin
12
12
  never a raw hex/`rgb()`/`hsl()`. Charts are visx via `basalt-ui/charts`: every single-plot chart
13
13
  composes `CartesianChart` (owns margins, scales, axes, grid, cursor, tooltip — draw only marks);
14
14
  legends/tooltip rows are DERIVED from `series`, never hand-authored (`basalt/hand-rolled-plot` +
15
- `basalt/chart-legend-literal` enforce both); `DualPanel`/`Donut`/`Heatmap` are the declared
16
- exceptions. Add a kind on the third repeat, don't loosen the primitives. `basalt-ui/charts` and
15
+ `basalt/chart-legend-literal` enforce both); `DualPanel`/`BandStrip`/`MirroredBars` declare
16
+ themselves exceptions, and `Donut`/`Heatmap` render no plot-assembly element so the rule never
17
+ fires on them. Add a kind on the third repeat, don't loosen the primitives. `basalt-ui/charts` and
17
18
  `basalt-ui/tokens` are Mantine-free internally (a framework invariant, not something your own app
18
19
  code must follow) — never import `@visx/*` outside a `charts/` directory (oxlint-enforced).
19
20
  Toolchain is oxlint + oxfmt (no ESLint/Biome/Prettier) and `basalt-ui check-theme` guards the
@@ -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
@@ -245,8 +245,49 @@ function allComments(context) {
245
245
  }
246
246
 
247
247
  /**
248
- * True when a NODE-scoped `theme-allow` covering `ruleId` sits on `node`'s own source line or the
249
- * line above — or when the file carries a `theme-allow-file` declaration for it.
248
+ * Every comment that REACHES `nodeLine` — the comment on the node's own line, the one directly
249
+ * above it, and any further comment separated from that one by nothing but comment.
250
+ *
251
+ * This is the plugin's half of "which node does this annotation scope to", and it has to answer the
252
+ * same question `src/guard`'s forward walk answers: **an annotation reaches the first line below its
253
+ * comment that is not itself comment.** The test used to be a flat `end.line === nodeLine ||
254
+ * nodeLine - 1`, which is the same rule only when the annotation is the LAST comment above the node
255
+ * — so anything sitting between the annotation and the node, comment included, was reported here
256
+ * while the guard waived it:
257
+ *
258
+ * ```text
259
+ * // theme-allow raw-hex — the vendor brand hex, kept in sync with ← a reason that wrapped
260
+ * // the marketing site's own palette onto a second comment
261
+ * const BRAND = '#ff0000'
262
+ *
263
+ * {/* theme-allow card-inset — … *\/}
264
+ * {/* 3. the density row *\/} ← an unrelated note
265
+ * <Card p={0} />
266
+ * ```
267
+ *
268
+ * The wrapped-reason one is not hypothetical: it is pinned in the guard's tests as a shape argo
269
+ * writes. A blank line still separates, because a blank line is not comment — which is what makes
270
+ * "this comment is not about the next statement" expressible in both halves.
271
+ *
272
+ * Returned bottom-up, which is also the cheap order to walk: stop at the first gap.
273
+ */
274
+ function reachingComments(context, nodeLine) {
275
+ const comments = allComments(context).toSorted((a, b) => a.loc.start.line - b.loc.start.line)
276
+ const out = []
277
+ let reach = nodeLine
278
+ for (let i = comments.length - 1; i >= 0; i--) {
279
+ const comment = comments[i]
280
+ if (comment.loc.end.line > nodeLine) continue
281
+ if (comment.loc.end.line < reach - 1) break
282
+ out.push(comment)
283
+ reach = Math.min(reach, comment.loc.start.line)
284
+ }
285
+ return out
286
+ }
287
+
288
+ /**
289
+ * True when a NODE-scoped `theme-allow` covering `ruleId` reaches `node` (see
290
+ * {@link reachingComments}) — or when the file carries a `theme-allow-file` declaration for it.
250
291
  *
251
292
  * The `ruleId` argument is what stops one exemption from being a blanket one: a `theme-allow
252
293
  * raw-hex — …` written for a color no longer silently switches off `card-inset` on the same line.
@@ -254,13 +295,11 @@ function allComments(context) {
254
295
  */
255
296
  function hasThemeAllow(context, node, ruleId) {
256
297
  if (hasFileDeclaration(context, ruleId)) return true
257
- const nodeLine = node.loc.start.line
258
- return allComments(context).some((comment) => {
259
- if (comment.loc.end.line !== nodeLine && comment.loc.end.line !== nodeLine - 1) return false
260
- return parseThemeAllows(comment.value).some(
298
+ return reachingComments(context, node.loc.start.line).some((comment) =>
299
+ parseThemeAllows(comment.value).some(
261
300
  (allow) => allow.scope === 'line' && allowCovers(allow, ruleId),
262
- )
263
- })
301
+ ),
302
+ )
264
303
  }
265
304
 
266
305
  /**
@@ -535,10 +574,12 @@ const cardInset = {
535
574
  // ── Rule 3 — chart-in-raw-surface ───────────────────────────────────────────────────────────────
536
575
 
537
576
  const CHART_TAGS = new Set([
577
+ 'BandStrip',
538
578
  'Bars',
539
579
  'Donut',
540
580
  'DualPanel',
541
581
  'Heatmap',
582
+ 'MirroredBars',
542
583
  'MultiLine',
543
584
  'StackedArea',
544
585
  'ZonedLine',