basalt-ui 1.23.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.
package/AGENTS.md CHANGED
@@ -62,29 +62,38 @@ duration/spring/ease literal in a `transition={{...}}` prop (enforced by `basalt
62
62
 
63
63
  ## Key commands
64
64
 
65
+ `init` and `tokens:css`/`fonts:css` are the only commands with no install to resolve, so `bunx` is
66
+ correct for them and wrong for everything else — see the note below.
67
+
65
68
  ```bash
66
- bunx basalt-ui init # FIRST STEP: scaffold .claude/rules/ + .claude/skills/, managed CLAUDE.md block, DESIGN.md seed, toolchain seeds
67
- bunx basalt-ui check-theme # fail on off-palette colors in consumer source
68
- bunx basalt-ui sync # reconcile managed files (.claude/rules/, .claude/skills/, CLAUDE.md block)
69
- bunx basalt-ui sync --check # CI freshness gate — exits non-zero if any managed file drifted
70
- bunx basalt-ui doctor # integration health — SKIPPED is a third outcome and exits non-zero
71
- bunx basalt-ui tokens:css # emit the --vx-* stylesheet (no React, no Mantine, no bundler)
72
- bunx basalt-ui fonts:css # emit the shipped --basalt-font-* stacks
73
- bunx basalt-ui info # print published surface map
74
- bunx basalt-ui info --json # stable JSON surface map
75
- bunx basalt-ui guard-hook # PreToolUse theme-guard adapter: reads a Write/Edit payload on stdin, denies off-palette writes
69
+ bunx basalt-ui init # FIRST STEP: scaffold .claude/rules/ + .claude/skills/, managed CLAUDE.md block, DESIGN.md seed, toolchain seeds
70
+ basalt-ui --version # one bare line, exit 0 — which CLI is actually running (also -v / version)
71
+ basalt-ui check-theme # fail on off-palette colors in consumer source
72
+ basalt-ui sync # reconcile managed files (.claude/rules/, .claude/skills/, CLAUDE.md block)
73
+ basalt-ui sync --check # CI freshness gate — exits non-zero if any managed file drifted
74
+ basalt-ui doctor # integration health — SKIPPED is a third outcome and exits non-zero
75
+ bunx basalt-ui tokens:css # emit the --vx-* stylesheet (no React, no Mantine, no bundler)
76
+ bunx basalt-ui fonts:css # emit the shipped --basalt-font-* stacks
77
+ basalt-ui info --json # stable JSON surface map
78
+ basalt-ui guard-hook # PreToolUse theme-guard adapter: reads a Write/Edit payload on stdin, denies off-palette writes
76
79
  ```
77
80
 
81
+ Every subcommand rejects a flag it does not accept, exiting 1 and naming it; an unknown command says
82
+ so above the usage block. Neither is ever ignored — `doctor --json` used to run doctor and exit 0.
83
+
78
84
  `check-theme`, `doctor` and `sync` resolve their project identically: `BASALT_CWD` wins, then the
79
85
  cwd, then the workspace packages the root declares, then a two-level layout scan (which is what a
80
86
  repo with no `workspaces` field and the install one level down needs). Two candidates is ambiguous
81
87
  and reported, never guessed. `sync` on a `tokens-only` consumer prints `n/a` and exits 0.
82
88
 
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.
89
+ **Run the local bin, not `bunx`.** `bunx` does not re-resolve a cached package, so it can execute
90
+ the version you just upgraded away from — one consumer filed a P0 against a 1.20.0 cache while
91
+ believing it was on 1.22.0. `init` and `sync` render every seeded script, CI step and hook with the
92
+ resolved local bin for this reason (`BASALT_BIN` overrides it). `basalt-ui --version` is the
93
+ one-second check.
85
94
 
86
95
  Register `guard-hook` in `.claude/settings.json` under `hooks.PreToolUse` with matcher
87
- `Write|Edit|MultiEdit` and command `bunx basalt-ui guard-hook` so the agent can't write off-palette
96
+ `Write|Edit|MultiEdit` and command `./node_modules/.bin/basalt-ui guard-hook` (what `init` seeds) so the agent can't write off-palette
88
97
  colors without a `theme-allow` comment.
89
98
 
90
99
  **The escape hatch is `theme-allow <rule-id> — <reason>`**, scoped to that one kind and to that one
package/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## [1.23.1](https://github.com/jkrumm/basalt-ui/compare/v1.23.0...v1.23.1) (2026-08-22)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * gate the two chart tag rules on where the tag came from ([0075224](https://github.com/jkrumm/basalt-ui/commit/007522416bf9a3269e5f45b7ae7f45203434bae7))
7
+ * make the CLI answer which version ran, and stop it failing open ([0edf50e](https://github.com/jkrumm/basalt-ui/commit/0edf50e1d6f61b349f989fd10fe384d4a91a9af1))
8
+ * stop a typo'd band state from asserting absence ([6c1515c](https://github.com/jkrumm/basalt-ui/commit/6c1515c3ecbf019d9971e5b247e81e5cce3ae316))
9
+
1
10
  # [1.23.0](https://github.com/jkrumm/basalt-ui/compare/v1.22.0...v1.23.0) (2026-08-22)
2
11
 
3
12
 
package/MIGRATING.md CHANGED
@@ -8,9 +8,10 @@ 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, 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.
11
+ row that was wrong. That pass corrected 4 table rows and 3 prose claims. Every section from 1.21.0
12
+ on was written against source, not against its commit messages — which is not a formality: one
13
+ 1.23.0 commit message describes a change it did not make (see `Unreleased` § Corrections). Check
14
+ the types, not this table, if the two disagree.
14
15
 
15
16
  **The newest section is headed `## Unreleased`, and stays that way until npm serves it.** This file
16
17
  is written before `semantic-release` picks the number, so a number written here is a guess — and it
@@ -39,6 +40,85 @@ version `CHANGELOG.md` does not record. This file is not in that set; the same d
39
40
  by convention. Rename the section at release, or leave it — a reader can resolve `Unreleased`
40
41
  against `CHANGELOG.md`, and could never resolve `1.21.1`.
41
42
 
43
+ **One type widened, nothing removed or renamed.** `BandStripSeries.formatValue` is now
44
+ `(d: T) => string | null`. `null` renders an em dash — an absent READING — which `''` never could:
45
+ `''` is a state whose label is the whole row. Every existing `(d) => string` still typechecks.
46
+
47
+ **A typo'd `BandSpan.state` no longer renders as absence.** A state naming no `series` entry used to
48
+ be skipped, so the band was simply not drawn — and on a measured/not-measured strip a missing band
49
+ is a claim about the data, not an "unknown". A misspelling asserted a reading nobody took, in a mark
50
+ indistinguishable from a real one.
51
+
52
+ | Key comes off | Dev | Production |
53
+ | ------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------ |
54
+ | the DATUM — `BandSpan.state`, `marker.state` | throws, naming the key and the valid set | a dashed neutral outline band + an `Unknown state` / `<key>` tooltip row |
55
+ | a PROP — `absentState`, `MirroredBars`' `up.key` / `down.key` | throws | throws |
56
+
57
+ The split is the whole design. `state` comes off data, so a feed that grows a state basalt has never
58
+ seen must degrade rather than take a dashboard down — while a typo, which is the same input, still
59
+ fails loudly wherever it is being written. The dashed neutral outline belongs to no legend entry and
60
+ no state fill, so it cannot read as data. Pane keys are props, never data-driven, so `assertPaneKey`
61
+ throws in every environment: an unresolvable `up.key` used to hide the pane AND its axis, which
62
+ reads as "that half measured zero" — the one thing `MirroredBars` exists to keep apart from absence.
63
+
64
+ **`chart-missing-aria-label` and `unframed-chart` are gated on where the tag came from.** Both key
65
+ on a JSX tag NAME, so a consumer's own 235-line `MirroredBars` — sharing nothing with the shipped
66
+ kind but the name — was told to pass an `ariaLabel` prop it does not accept, by a rule that presents
67
+ as a correctness finding. A tag is now skipped only when the file DEFINES a component of that name
68
+ and does not also import it from `basalt-ui`. Deliberately one-directional: a tag imported from
69
+ basalt-ui, one imported from a consumer barrel that re-exports it, and one the scan cannot attribute
70
+ at all still all fire. Verified old-vs-new over **945 source files across six repos: 0 findings
71
+ lost, 0 gained.**
72
+
73
+ **CLI: `--version` exists, and no subcommand fails open on a flag.** `--version` / `-v` / `version`
74
+ print one bare greppable line and exit 0, resolved BEFORE dispatch so it can never run a command to
75
+ answer "which basalt-ui is this". Six consumer reports reached for it in one round; all six fell
76
+ back to `info --json`.
77
+
78
+ The larger fix sits underneath it. **Every subcommand validates its flags** and exits 1 naming the
79
+ one it does not accept — `doctor --json` used to run doctor and exit **0**, and `check-theme
80
+ --audit-allow` scanned and reported success. An unknown COMMAND now says so above the usage block
81
+ instead of dumping help and letting the dump read like a choice.
82
+
83
+ - **`doctor` reads `basaltAppPlugin({ icons })`** out of the consumer's vite config instead of
84
+ hardcoding six filenames, so adopting 1.23.0's icons array stops producing a warning to generate
85
+ five files you deliberately lack. A named array is checked against itself (an icon missing from
86
+ `public/` still warns); an unparseable or absent config falls back to the six-filename check. It
87
+ can only narrow, never blind.
88
+ - **`doctor` and `sync` share one sentence about a parent install** (`parentInstallAdvice`). Run
89
+ from a package whose install is above it, `doctor` now names the parent and says `basalt-ui init`
90
+ is NOT the fix. Following its old advice literally scaffolded the second consumer that 1.22.0
91
+ exists to prevent.
92
+ - **Every seeded invocation resolves the local bin** (`basaltBinCommand`, overridable via
93
+ `BASALT_BIN`): the `lint` script, the CI steps, the `.claude` PreToolUse hook and doctor's own
94
+ advice all render `./node_modules/.bin/basalt-ui`. `bunx` does not re-resolve a package it has
95
+ cached — that is what made a round-7 report file a P0 against a 1.20.0 cache while believing it
96
+ was on 1.22.0, and the seed was shipping `bunx` into consumer CI in ten places.
97
+ `configs/lefthook.yml`'s `${BASALT_BIN:-bunx --no-install basalt-ui}` default is unchanged and
98
+ deliberate: `--no-install` fails loudly instead of downloading a stranger.
99
+ - **`tokens:css --check` blanks the provenance line (line 2) before comparing**, so a version bump
100
+ alone no longer forces a no-op commit in a tokens-only consumer, where the gate is byte-equality.
101
+ A stale provenance line is now a note on an otherwise-passing check. The `@generated` header is
102
+ still emitted byte-identical — the line stays, it just stops gating.
103
+
104
+ ### Corrections to the record
105
+
106
+ - **All six round-8 reports said `basalt-ui --version` "exits 0 printing usage". It exited 1, to
107
+ stderr** — the dispatcher's `default:` branch has always been `console.error(USAGE); return 1`.
108
+ The real fail-open was an unknown FLAG, which no report tested, and the misdiagnosis was relayed
109
+ verbatim into the fix brief. Report the symptom you measured, not the one you inferred from it.
110
+ - **`cb4e5b7`'s message is wrong** (`b9b99a6` on `origin/feat/round-7-band-kinds` is the same commit
111
+ pre-merge), and it is on `master` where it cannot be rewritten. It claims it taught
112
+ `unframed-chart` the two new kinds; it widened `CHART_ENTRY_POINT_TAG`, which only
113
+ `chart-missing-aria-label` reads. `unframed-chart` keys on `<ChartLegend items={[` and carries no
114
+ kind list at all — there was never an asymmetric pair to fix.
115
+ - **Open question, not a plan.** The import gate does not make `CHART_ENTRY_POINT_TAG` or the oxlint
116
+ plugin's `CHART_TAGS` redundant: that list still answers _which_ tags owe an `ariaLabel`. The gate
117
+ only converts a kind missing from the list from a false positive into an under-report. Collapsing
118
+ the two lists is a separate, larger change and has not been made.
119
+
120
+ ## 1.23.0 — two band kinds, an x-tick seam, CLI resolution
121
+
42
122
  **No export removed or renamed; six new runtime exports and nine new types.** Nothing you wrote
43
123
  changed. Every entry is additive, and the batch runs the other way from the last three: those made
44
124
  the guard stricter, this one makes the framework more expressive.
@@ -72,7 +152,9 @@ unchanged and still works; omit both and ticks are chosen to fit (`smartTicks`),
72
152
  Reach for it on a dense time axis: the tick choosers append the final key unconditionally, so a
73
153
  COUNT that does not land on the last index paints two labels on top of each other at the right edge
74
154
  — 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.
155
+ went 200 → 170 lines; it shrinks, it does not disappear. (The promise was 160. The 10-line miss is
156
+ docblock — the port's own JSDoc grew, because the helper stopped being a fallback and became the one
157
+ seam every chart on the page passes through.)
76
158
 
77
159
  **`basaltAppPlugin`'s `icons` takes an array now.**
78
160
 
package/README.md CHANGED
@@ -450,6 +450,16 @@ bar panes over one x scale and one baseline, each in its own domain. Both fold t
450
450
  width, hatch the share of a slot nothing measured, and share the page cursor like every other
451
451
  chart.
452
452
 
453
+ On a strip, `series` IS the state set, so a `state` naming no entry has no colour, no legend entry
454
+ and no tooltip row. It **throws in dev**, naming the key and the valid set; in production it draws a
455
+ dashed neutral outline band — a treatment no legend entry and no state fill uses — plus an
456
+ `Unknown state` tooltip row. Drawing nothing was the old behaviour and the wrong one: on a
457
+ measured/not-measured strip a missing band claims a coverage gap. `absentState` and `MirroredBars`'
458
+ `up.key`/`down.key` are PROPS rather than data, so those throw in every environment; an
459
+ unresolvable pane key used to hide the pane and its axis, which reads as a measured zero.
460
+ `BandStripSeries.formatValue` returns `string | null`, `null` rendering an em dash — an absent
461
+ reading, distinct from `''`, which is a state whose label is the whole row.
462
+
453
463
  Which x ticks get painted is `xTickValues?: (keys, xMax) => readonly string[]`, on
454
464
  `CartesianChart` and forwarded by `Bars`/`MultiLine`/`StackedArea`/`ZonedLine` and both band kinds.
455
465
  It resolves ahead of the `xTicks` COUNT, which is unchanged; omit both and ticks are chosen to fit.
@@ -694,19 +704,24 @@ Current slots: `series` (read by `./charts` + `./tokens`), `commands` (read by `
694
704
  ## CLI
695
705
 
696
706
  ```bash
697
- bunx basalt-ui init # scaffold doctrine into a consumer repo
698
- bunx basalt-ui sync # three-way diff against .basalt/manifest.json after a basalt-ui upgrade
699
- bunx basalt-ui sync --check # CI drift gate — non-zero exit on any managed-file drift
700
- bunx basalt-ui check-theme # palette guard — fails on colors that bypass the central --vx-* system
701
- bunx basalt-ui doctor # integration health: basalt-resolves, guard-scan, oxlint-preset, manifest presence, node_modules-vs-manifest version, spacing-scale drift, and a cross-package `ai` major-version parity check (HARD failure — walks every workspace manifest, exemptable via `basalt.aiMajorSkewReason` — see above) + warn on missing basaltAppPlugin icon files under public/
702
- bunx basalt-ui doctor --tokens-only # force the Mantine-free profile (auto-detected otherwise; --framework forces the full set)
707
+ bunx basalt-ui init # scaffold doctrine into a consumer repo — the one command with no install to resolve
708
+ basalt-ui --version # one bare line, exit 0 (also -v / version) — which CLI is actually running
709
+ basalt-ui sync # three-way diff against .basalt/manifest.json after a basalt-ui upgrade
710
+ basalt-ui sync --check # CI drift gate — non-zero exit on any managed-file drift
711
+ basalt-ui check-theme # palette guard — fails on colors that bypass the central --vx-* system
712
+ basalt-ui doctor # integration health: basalt-resolves, guard-scan, oxlint-preset, lefthook-preset, manifest presence, node_modules-vs-manifest version, spacing-scale drift, and a cross-package `ai` major-version parity check (HARD failure — walks every workspace manifest, exemptable via `basalt.aiMajorSkewReason` — see above) + warn on an icon file basaltAppPlugin references and public/ does not have
713
+ basalt-ui doctor --tokens-only # force the Mantine-free profile (auto-detected otherwise; --framework forces the full set)
703
714
  bunx basalt-ui tokens:css --out src/tokens.css # emit the --vx-* stylesheet as a file you own
704
715
  bunx basalt-ui fonts:css --out src/fonts.css # emit the shipped --basalt-font-* stacks
705
- bunx basalt-ui info # human-readable surface map: subpath, layer, rule, skills, optional peers
706
- bunx basalt-ui info --json # same map as stable JSON (InfoOutput shape)
707
- bunx basalt-ui help # full usage; every subcommand also takes --help / -h
716
+ basalt-ui info --json # surface map as stable JSON (InfoOutput shape); bare `info` is the human-readable form
717
+ basalt-ui help # full usage; every subcommand also takes --help / -h
708
718
  ```
709
719
 
720
+ **Nothing fails open.** `--version` resolves before dispatch, so it can never run a command to
721
+ answer which CLI ran. Every subcommand validates its flags and exits 1 naming the first one it does
722
+ not accept — `doctor --json` used to run doctor and exit **0**, and `check-theme --audit-allow`
723
+ scanned and reported success. An unknown command says so above the usage block.
724
+
710
725
  `doctor` reports **`SKIPPED`** as a third outcome beside pass/warn/fail and exits non-zero on it —
711
726
  "All checks passed" is only printable when every check actually ran. **`check-theme`, `doctor` and
712
727
  `sync` all resolve their project the same way**: `BASALT_CWD` wins, then the cwd itself, then the
@@ -715,13 +730,15 @@ ambiguous, never guessed. The scan is what a repo declaring **no `workspaces` fi
715
730
  the install one level down and nothing declaring it, every command answered about the root and
716
731
  found nothing there: `check-theme` printed "no off-palette colors" having scanned zero files, and
717
732
  `doctor` inferred `tokens-only` for a full Mantine consumer. `tokens:css` and `fonts:css` both take
718
- `--check` as a CI drift gate.
733
+ `--check` as a CI drift gate, comparing everything but the header's provenance line — so a
734
+ basalt-ui version bump alone never forces a no-op commit in a tokens-only consumer.
719
735
 
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.
736
+ **Run the local bin, not `bunx`.** `bunx` does not re-resolve a package it has already cached, so a
737
+ `bunx basalt-ui …` run can execute a version you upgraded away from — which is how one consumer
738
+ filed a P0 against a 1.20.0 cache while pinned to 1.22.0. `init` and `sync` now render every seeded
739
+ script, CI step, hook and piece of doctor advice with the resolved `./node_modules/.bin/basalt-ui`
740
+ (`BASALT_BIN` overrides it, and `configs/lefthook.yml` keeps `bunx --no-install` as a deliberately
741
+ loud fallback). `basalt-ui --version` is the one-second check.
725
742
 
726
743
  `sync` strategy per file: unchanged since last write → overwrite; locally edited → skip (show diff);
727
744
  missing → recreate. `--force` overwrites local edits. The summary counts `created` and `recreated`
@@ -736,7 +753,9 @@ release.
736
753
  `check-theme` and `doctor` do, and since 1.22.0 **exits 1** when the resolved project has no
737
754
  `.basalt/manifest.json`, naming the install it found above instead of scaffolding a second one
738
755
  beside it. Run it at the package that holds the manifest, or point `BASALT_CWD` at that package.
739
- Creating an install is `basalt-ui init`'s decision.
756
+ Creating an install is `basalt-ui init`'s decision — and `doctor` now says the same sentence from
757
+ the same directory, where it used to prescribe `init` and so recommend the very scaffold that
758
+ refusal exists to prevent.
740
759
 
741
760
  ---
742
761
 
@@ -193,7 +193,11 @@ web-app-manifest-512x512.png
193
193
 
194
194
  Generate the set with `@vite-pwa/assets-generator` (or realfavicongenerator.net) from a single
195
195
  source image — it lives in the CONSUMER's own devDependencies, not basalt-ui's. `basalt-ui doctor`
196
- warns (does not fail) when `public/` exists but is missing one of these files.
196
+ warns (does not fail) when `public/` exists but is missing one of these files — and it now
197
+ parses `basaltAppPlugin`'s `icons` option out of your vite config first, so it checks the files you
198
+ declared rather than the six it assumes. `icons: false` or an empty array is "nothing to check"; an
199
+ unparseable or absent config falls back to the six defaults. Adopting the array is no longer a way
200
+ to earn a warning.
197
201
 
198
202
  **If your `public/` does not match those six filenames, name your icons instead.** `icons` takes
199
203
  `false | { dir?: string } | readonly BasaltAppIcon[]`, and an array uses the manifest's own field
@@ -217,7 +221,9 @@ One SVG at `sizes: 'any'` is a complete, installable icon set for a single-page
217
221
  shape every non-scaffolded consumer already has. If you keep a hand-written `manifest.webmanifest`
218
222
  only because the plugin could not name your icon, delete it: the generated manifest reproduces it
219
223
  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.
224
+ takes the permanent `theme-allow-file` on that file with it. **Delete that app's own
225
+ `<link rel="manifest">` in the same change** — the plugin injects its own, so leaving both ships two
226
+ manifest links pointing at different files, one of them a 404 the moment the hand-written file goes.
221
227
 
222
228
  `basaltAppPlugin` also emits `site.webmanifest` (served in dev too) with explicit `id`/`scope`/
223
229
  `start_url` — pass `manifest: false` to skip it, or `icons: false` to skip the icons. Since 1.22.0
@@ -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
 
@@ -348,6 +350,8 @@ per-series `getValue` accessors on `ChartSeries<T>`):
348
350
  hatch; `marker` carries a fact that must not be readable off the `fill` ramp. `cursorResolution`
349
351
  defaults `'leading'` here (a band IS a bucket), not `'nearest'`. Composes `ChartFrame` directly —
350
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).
351
355
  - **`MirroredBars`** — two BAR panes over one x scale, mirrored around one baseline, each pane in
352
356
  its own domain. `up`/`down` take `{ key, max?, autoMaxFloor?, ticks?, format }`; `upFraction`
353
357
  (default 0.35) is the up pane's share of the band height, which is where the shared baseline sits;
@@ -360,6 +364,29 @@ Both band kinds fold their domain by width (`fold: { minBandPx?, merge }`), and
360
364
  exported so a consumer can test their merge against the grouping that will actually run.
361
365
  `HatchPattern` / `hatchFill` / `hatchSizeFor` ship the absence fill.
362
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".
389
+
363
390
  `ZonedLine` and `MultiLine` also accept `xZones?: XZoneSpec[]` — the vertical counterpart to a
364
391
  kind's horizontal zone bands, for marking a time window rather than a value range. Each
365
392
  `{ from?, to?, fill, align? }` bound is a `getX` **domain key** (the label string the kind's
@@ -512,6 +539,15 @@ left, 12% right): uncapped, half a `DD.MM HH:MM` label is 48px of a 338px chart.
512
539
  probes real tick labels from both panes, so it takes the plain measured margin and neither the
513
540
  floor nor the cap applies.
514
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
+
515
551
  `chartMargin(opts?)` (`basalt-ui/tokens`, also re-exported from `basalt-ui/charts`) still exists,
516
552
  but only matters for a bespoke chart that composes `ChartFrame` directly (not `CartesianChart`) and
517
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
@@ -120,13 +120,26 @@ the guard with that one occurrence neutralized, so the verdict comes from the sc
120
120
  parsing the waiver's text:
121
121
 
122
122
  ```text
123
+ theme-allow annotations (3):
123
124
  src/shell/x.tsx:42 suppresses raw-surface@43
124
125
  src/theme/y.ts:18 SUPPRESSES NOTHING — dead, delete it
125
- 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):
126
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). …
127
134
  ```
128
135
 
129
- 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
130
143
  waiver**, so it wires into CI. A waiver that stopped covering anything is not harmless: it silently
131
144
  covers the next real finding on that line. Basalt's own source had one; it is deleted.
132
145
 
@@ -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)
@@ -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