basalt-ui 1.20.0 → 1.21.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 (87) hide show
  1. package/AGENTS.md +9 -5
  2. package/CHANGELOG.md +19 -0
  3. package/MIGRATING.md +147 -49
  4. package/README.md +42 -8
  5. package/agent/rules/basalt-app.md +41 -0
  6. package/agent/rules/basalt-charts.md +15 -12
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-router.md +57 -5
  9. package/agent/rules/basalt-state.md +21 -3
  10. package/agent/rules/basalt-tokens.md +73 -16
  11. package/agent/skills/basalt-app/SKILL.md +4 -1
  12. package/agent/skills/basalt-charts/SKILL.md +5 -3
  13. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  14. package/configs/lefthook.yml +48 -15
  15. package/configs/oxlint-plugin.js +181 -68
  16. package/configs/oxlint-plugin.test.ts +215 -9
  17. package/dist/charts/kinds/Bars.d.ts +1 -1
  18. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  19. package/dist/charts/kinds/Donut.d.ts +1 -1
  20. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  21. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  22. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  23. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  24. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  25. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  26. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  27. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  28. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  29. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  30. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  31. package/dist/cli/index.d.ts +48 -14
  32. package/dist/cli/index.d.ts.map +1 -1
  33. package/dist/cli/index.js +328 -66
  34. package/dist/cli/waiver-audit.test.d.ts +2 -0
  35. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  36. package/dist/guard/index.d.ts +47 -2
  37. package/dist/guard/index.d.ts.map +1 -1
  38. package/dist/guard/index.js +176 -31
  39. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  40. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  41. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  42. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  43. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  44. package/dist/router-tanstack/search-param-store.js +31 -3
  45. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  46. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  47. package/dist/shell/index.d.ts +12 -5
  48. package/dist/shell/index.d.ts.map +1 -1
  49. package/dist/shell/index.js +24 -6
  50. package/dist/tokens/index.d.ts +3 -1
  51. package/dist/tokens/index.d.ts.map +1 -1
  52. package/dist/tokens/index.js +5 -1
  53. package/dist/tokens/palette.d.ts +2 -2
  54. package/dist/tokens/palette.js +2 -2
  55. package/dist/tokens/text-scale.test.d.ts +2 -0
  56. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  57. package/dist/tokens.css +4 -2
  58. package/dist/vite.d.ts +24 -2
  59. package/dist/vite.d.ts.map +1 -1
  60. package/dist/vite.js +54 -13
  61. package/llms.txt +2 -2
  62. package/package.json +1 -1
  63. package/src/charts/kinds/Bars.tsx +1 -1
  64. package/src/charts/kinds/Donut.tsx +1 -1
  65. package/src/charts/kinds/DualPanel.tsx +2 -2
  66. package/src/charts/kinds/Heatmap.tsx +1 -1
  67. package/src/charts/kinds/MultiLine.tsx +1 -1
  68. package/src/charts/kinds/StackedArea.tsx +1 -1
  69. package/src/charts/kinds/ZonedLine.tsx +1 -1
  70. package/src/cli/index.ts +607 -128
  71. package/src/cli/init-sync.test.ts +2 -0
  72. package/src/cli/template-ledger.test.ts +1 -1
  73. package/src/cli/toolchain-wiring.test.ts +162 -0
  74. package/src/cli/waiver-audit.test.ts +131 -0
  75. package/src/guard/check-source.test.ts +441 -1
  76. package/src/guard/index.ts +430 -60
  77. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  78. package/src/router-tanstack/search-param-store.test.ts +135 -0
  79. package/src/router-tanstack/search-param-store.ts +90 -8
  80. package/src/shell/app-mobile-nav.tsx +1 -1
  81. package/src/shell/index.test.tsx +52 -0
  82. package/src/shell/index.tsx +64 -13
  83. package/src/tokens/index.ts +24 -1
  84. package/src/tokens/palette.ts +2 -2
  85. package/src/tokens/text-scale.test.ts +51 -0
  86. package/src/vite.test.ts +125 -6
  87. package/src/vite.ts +148 -20
package/AGENTS.md CHANGED
@@ -79,11 +79,15 @@ Register `guard-hook` in `.claude/settings.json` under `hooks.PreToolUse` with m
79
79
  `Write|Edit|MultiEdit` and command `bunx basalt-ui guard-hook` so the agent can't write off-palette
80
80
  colors without a `theme-allow` comment.
81
81
 
82
- **The escape hatch is `theme-allow <rule-id> — <reason>`**, scoped to that one kind. It is honoured
83
- on the reported line, on a comment-only line directly above it, and — in CSS — from a trailing
84
- comment back over the declaration it terminates. A bare `theme-allow` still waives everything but
85
- reports `theme-allow-unscoped`. A word in the id slot that names no rule waives nothing — only a
86
- genuinely bare comment is the blanket form.
82
+ **The escape hatch is `theme-allow <rule-id> — <reason>`**, scoped to that one kind and to that one
83
+ node/line. It must START its comment (after `//`, `/*`, `<!--`, a block gutter `*`, or whitespace),
84
+ and is honoured on the reported line, on a comment-only line directly above it (reaching the first
85
+ CODE line below, through the rest of its comment block), and — in CSS — from a trailing comment back
86
+ over the declaration it terminates. Whole-file scope is `theme-allow-file <id>… — <why>`; a bare
87
+ `theme-allow-file` waives nothing. A bare `theme-allow` still waives everything on its placement but
88
+ reports `theme-allow-unscoped`. A word in the id slot that names no rule waives nothing.
89
+ `.json`/`.webmanifest` use a `"basalt:theme-allow[-file]"` member. Audit them all with
90
+ `basalt-ui check-theme --audit-allows`, which exits 1 on a waiver that suppresses nothing.
87
91
 
88
92
  > **Framework-internal only** — `bunx basalt-ui check-coverage` is a self-consistency gate for the
89
93
  > basalt-ui repo itself (asserts SURFACES ↔ rule files ↔ skill files ↔ package.json exports). It
package/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ # [1.21.0](https://github.com/jkrumm/basalt-ui/compare/v1.20.0...v1.21.0) (2026-08-22)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * delete a waiver that stopped covering anything ([3984281](https://github.com/jkrumm/basalt-ui/commit/3984281fff6313c225bdf1e185de4a427d2ef4df))
7
+ * emit 0.1, not 0.10, from the shadow palette itself ([48adfff](https://github.com/jkrumm/basalt-ui/commit/48adfffcc9ad7713cacc07c1fc090b219fd175fa))
8
+ * let the real color scheme win, and keep <meta charset> in its window ([8708c85](https://github.com/jkrumm/basalt-ui/commit/8708c85f1488b25e6a059ee879dcadf6350278a2))
9
+ * make an annotation start its comment, and let it scope to one node ([a4cf78c](https://github.com/jkrumm/basalt-ui/commit/a4cf78c546d291c33a27bf94d7636758072a1005))
10
+ * persist sidebar collapse through basalt's own state primitive ([c576040](https://github.com/jkrumm/basalt-ui/commit/c576040172dfeb8d66b52909bbcf55b7b1f91c70))
11
+ * resolve the paths the wiring checks only matched, and audit the waivers ([c595181](https://github.com/jkrumm/basalt-ui/commit/c5951817ed01d7729f031bba2f3da50c3bf60666))
12
+ * restore the palette fixture to emitter bytes ([605e240](https://github.com/jkrumm/basalt-ui/commit/605e240387a417f57ae8070f97bad79d97545d60))
13
+
14
+
15
+ ### Features
16
+
17
+ * give the type ladder the two rungs consumers could only waive ([29b9ef3](https://github.com/jkrumm/basalt-ui/commit/29b9ef3767282dffb51fb52138d6ae4f1d24dd62))
18
+ * make the search-param store's reader reachable from the API ([6924970](https://github.com/jkrumm/basalt-ui/commit/6924970f410b82ba5cfc65fd1d5088124934fc8f))
19
+
1
20
  # [1.20.0](https://github.com/jkrumm/basalt-ui/compare/v1.19.1...v1.20.0) (2026-08-22)
2
21
 
3
22
 
package/MIGRATING.md CHANGED
@@ -6,8 +6,11 @@ nav definition" does not say `ChartHoverSync` was deleted. This file is that hal
6
6
  renamed exports per minor, with the replacement.
7
7
 
8
8
  Reconstructed from `git diff` over the published export surface across `v1.0.0..v1.19.1`, then
9
- cross-checked against the repo's `scripts/export-surface.json` snapshot. Verified 2026-08-22; the
10
- 1.20.0 section below is written from the commits on `master` that produce it.
9
+ cross-checked against the repo's `scripts/export-surface.json` snapshot. **Every replacement below
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.20.1 section was
12
+ written against source, not against its commit messages. Check the types, not this table, if the two
13
+ disagree.
11
14
 
12
15
  **No majors, by policy.** A rename or a removal ships as a plain `feat:` on the 1.x line, so a minor
13
16
  bump can require code changes. Skipping several at once is the expensive case — read every section
@@ -22,11 +25,86 @@ at 1.3.0, `./agent-chat` at 1.10.0.
22
25
 
23
26
  ---
24
27
 
28
+ ## 1.20.1 — the `theme-allow` grammar
29
+
30
+ **No export removed or renamed.** One break, and it is in the escape hatch itself: **file scope must
31
+ now be spelled `theme-allow-file`.** At 1.20.0 an annotation that named a rule and gave a reason —
32
+ the exact shape the rule's own message asks for — was promoted to a whole-file declaration, which is
33
+ why per-node scoping never actually shipped. The two forms are now distinct:
34
+
35
+ ```text
36
+ theme-allow → this node/line, EVERY rule (reports theme-allow-unscoped)
37
+ theme-allow <id>[, <id>…] [— <why>] → this node/line, those rules
38
+ theme-allow-file <id>[, <id>…] — <why> → the WHOLE FILE, those rules; a bare one waives NOTHING
39
+ "basalt:theme-allow[-file]": "<id>… — <why>" → the same two, for JSON / .webmanifest
40
+ ```
41
+
42
+ **The migration is one word per file declaration.** Measured across the consumer sweep: linewatch
43
+ 0 → 11 findings, argo 0 → 6, rb 0 → 0 — all `warn`, so **no build changes colour**.
44
+
45
+ ```diff
46
+ -// theme-allow hand-rolled-plot — two panes over one x scale
47
+ +// theme-allow-file hand-rolled-plot — two panes over one x scale
48
+ ```
49
+
50
+ **An annotation must now START its comment** — after `//`, `/*`, `<!--`, a block-comment gutter `*`,
51
+ or nothing but whitespace. Both parsers used a bare substring search, so a comment that merely
52
+ _mentioned_ the token parsed as the legacy blanket form and switched every rule off on the line
53
+ below. linewatch documented its own waivers in a docblock and thereby disarmed the file — a false
54
+ NEGATIVE, and the reason this ships as a break rather than a grace period. Every annotation anyone
55
+ actually writes still qualifies; a sentence about the escape hatch no longer waives anything.
56
+
57
+ **A comment-only annotation now reaches the first CODE line below it**, walking through the rest of
58
+ its own comment block. Before, a reason that wrapped onto a second line, or a docblock's `*/`,
59
+ absorbed the waiver and the natural shape silently waived nothing — argo hit that three times in one
60
+ upgrade. A blank line still ends the block.
61
+
62
+ **`.json` / `.webmanifest` finally have a waiver.** They have been scanned since 1.20.0 and cannot
63
+ hold a comment, so their findings were unwaivable and the printed remedy prescribed something
64
+ impossible; both consumers fell back to a blanket `exemptRules`. Use a member key — but for a
65
+ manifest, `basaltAppPlugin` is the first remedy, since a hand-copied hex drifts from the palette:
66
+
67
+ ```json
68
+ { "basalt:theme-allow-file": "raw-hex — a PWA manifest theme_color must be a literal hex" }
69
+ ```
70
+
71
+ **Two API-shaped fixes worth acting on:**
72
+
73
+ | Change | What to do |
74
+ | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
75
+ | `createSearchParamStore` / `createMultiSearchParamStore` gain `linkSearch` | replace every nav link's `search: { <param>: '<literal>' }` with `search: <store>.linkSearch`, passed BY REFERENCE. A module-scope literal pins the fallback on every click — argo's reader had ZERO call sites, so "remember my window" had never worked |
76
+ | `BasaltShell` collapse moves to `createPersistedState` | the key is now `basalt:<storageKey>` holding `{ v, value }`; read it with `readPersistedValue(storageKey, 1)`. A one-time migration adopts the raw pre-1.20.1 value, so the sidebar does not re-expand on upgrade |
77
+
78
+ **New, additive:**
79
+
80
+ | Surface | Note |
81
+ | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
82
+ | `check-theme --audit-allows` | every waiver and every `exemptRules` entry with what it still suppresses, proved by re-running the guard with that one occurrence neutralized. Exits 1 on a dead waiver — wire it into CI |
83
+ | `basalt.exemptRules` takes paths, globs and a reason | relative paths and directory prefixes (`public/site.webmanifest`, `src/agent`), globs (`*` stops at `/`, `**` does not, a slash-free glob also matches the basename), and `{ paths, reason }`. Entries that suppress nothing are now reported |
84
+ | `doctor` → `lefthook-preset` | hard-fails a broken `extends` target. lefthook merges a missing target into ZERO commands and exits 0, so a stale path leaves a repo with no pre-commit gate and a clean `lefthook dump`. `sync` reports the same seam |
85
+ | `basaltAppPlugin({ colorScheme })` | `'dark'` (default) / `'light'` / `'auto'` / `false`. Set it to whatever the app passes as `defaultColorScheme` — before this, a light-scheme consumer got dark native controls permanently |
86
+ | `VX.text.nano` (10px) + `VX.text.display` (30px) | the two rungs `inline-font-size` could previously only be waived for. A 20px rung was rejected: 21/20 = 1.05 is below the ladder's 1.06×–1.17× band, so `h2` is the remedy |
87
+ | `sync` backfills `basalt.roots` | only `init` wrote it, so every existing consumer sat on the undeclared `src` default while `guard-scan` passed. A declared value is never overwritten |
88
+ | `basalt/shadow-basalt-export` reads all nine barrels | the charts layer included. Still exact-name-only — a **tripwire, not coverage** |
89
+
90
+ Also: `check-theme`'s `inline-spacing` no longer reads a unitless number in a plain options bag as
91
+ CSS (`fitBounds({ padding: 48 })` stops reporting); the shipped `oxfmt` pre-commit job drops back to
92
+ `*.{ts,tsx,js,jsx,css}` and gains `--no-error-on-unmatched-pattern`; `tokens:css` emits `0.1` rather
93
+ than `0.10`, so a committed sheet stops failing prettier — re-run the command to pick it up.
94
+
95
+ **The lefthook preset overrides YOU, not the other way round.** An `extends` target wins on a
96
+ colliding key: declare `pre-commit.commands.oxfmt.run` (or `glob:`) in your own file and **yours**
97
+ is the one silently discarded. Only keys the preset does not define merge in. The guard job runs
98
+ `${BASALT_BIN:-bunx --no-install basalt-ui}`; that shell default is the sanctioned seam, set via
99
+ `env:`, which does merge.
100
+
25
101
  ## 1.20.0 — enforcement
26
102
 
27
103
  **No export removed or renamed.** The whole delta is that things which used to pass now report. If
28
- your build goes red on this upgrade, that is the release working; every new kind and rule lands
29
- `warn`, so nothing here fails a build on its own.
104
+ your build goes red on this upgrade, that is the release working. Every new **kind** and **rule**
105
+ lands `warn` — but two other changes in this release do fail a build: `basalt/raw-size-literal`
106
+ promotes to `error`, and the widened markup scan reads `index.html` / `.webmanifest`, where a raw
107
+ hex is an `error`-severity colour kind. Two consumers exited 1 on exactly that.
30
108
 
31
109
  | Change | What you'll see | What to do |
32
110
  | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
@@ -69,9 +147,18 @@ line is usually a multi-line opening tag or a `{expr}` child), and in CSS a trai
69
147
  reaching back over the declaration it terminates, which is what survives the shipped `oxfmt`
70
148
  reflowing a long `background-color` so the hex lands above the comment.
71
149
 
72
- **`basalt/hand-rolled-plot` no longer grants whole-file immunity.** Every assembly node is reported
73
- and waived individually. A file-scoped exception now needs a written declaration naming the rule and
74
- giving a reason, anywhere in the file: `// theme-allow hand-rolled-plot — two panes over one x scale`.
150
+ **`basalt/hand-rolled-plot` waivers must now be written deliberately.** Every assembly node is
151
+ reported individually, and a waiver is no longer picked up off whatever comment happened to sit on
152
+ the file's first assembly node — it needs a written declaration naming the rule and giving a reason,
153
+ anywhere in the file: `// theme-allow hand-rolled-plot — two panes over one x scale`.
154
+
155
+ **But the waiver is still whole-file, and per-node scoping is not expressible at 1.20.0.** Naming
156
+ the rule AND giving a reason is what `hasFileDeclaration` matches, at any line — so a comment
157
+ intended for one node silences the file. Dropping the reason keeps it node-scoped in the oxlint
158
+ plugin, but then `check-theme` reports `theme-allow-unscoped ("no reason")`. The two halves of the
159
+ contract intersect at exactly one legal shape and that shape is whole-file. Write the declaration in
160
+ the component's docblock, where it reads as the file-level decision it is. **Fixed in 1.20.1** —
161
+ see that section; the declaration moves to `theme-allow-file`.
75
162
 
76
163
  **`doctor` will go red.** `SKIPPED` is a third outcome beside pass/warn/fail and exits non-zero on
77
164
  its own — "All checks passed" is only printable when every check RAN. Three new hard checks:
@@ -98,18 +185,18 @@ each root's PARENT now contributes its `index.html` and `public/` tree.
98
185
 
99
186
  ## 1.19.0 — nav
100
187
 
101
- | Removed / renamed | Replacement | Note |
102
- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
103
- | `NavLinkRenderer` (type) | `defineNav` + `{...useNav(NAV)}` (`basalt-ui/router-tanstack`), or `SidebarItem.Anchor` | basalt now paints every nav pixel |
104
- | `BasaltShellProps.renderNavLink`, `AppSidebarProps.renderNavLink` | same | |
105
- | `BreadcrumbLinkRenderer` (type) | `AppBreadcrumbs.parentAnchor` | |
106
- | `BasaltShellProps.renderBreadcrumbLink` | same | |
107
- | `BasaltShellProps.sidebarFooterExtra` | `mobileNav.moreExtra` | its only host was the mobile drawer; it rendered nowhere on desktop |
108
- | `AppSidebarProps.footerExtra`, `AppSidebarProps.onClose` | — | the full-height mobile sidebar drawer is deleted |
109
- | `MobileNavItem` (type) | `MobileNavSlot` | |
110
- | `MobileNavSection` (type) | `MobileNavGroup` | |
111
- | `MobileNavLinkRenderer` (type) | `MobileNavModel` | |
112
- | `SidebarSection.mobileTab` | `SidebarSection.mobile`: `'tab' \| 'more' \| 'hidden'` (`true`/`false` still accepted) | |
188
+ | Removed / renamed | Replacement | Note |
189
+ | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
190
+ | `NavLinkRenderer` (type) | `defineNav` + `{...useNav(NAV)}` (`basalt-ui/router-tanstack`), or `SidebarItem.Anchor` | basalt now paints every nav pixel |
191
+ | `BasaltShellProps.renderNavLink`, `AppSidebarProps.renderNavLink` | same | |
192
+ | `BreadcrumbLinkRenderer` (type) | `AppBreadcrumbs.parentAnchor` | |
193
+ | `BasaltShellProps.renderBreadcrumbLink` | same | |
194
+ | `BasaltShellProps.sidebarFooterExtra` | `mobileNav.moreExtra` | its only host was the mobile drawer; it rendered nowhere on desktop |
195
+ | `AppSidebarProps.footerExtra`, `AppSidebarProps.onClose` | — | the full-height mobile sidebar drawer is deleted |
196
+ | `MobileNavItem` (type) | `MobileNavSlot` | |
197
+ | `MobileNavSection` (type) | `MobileNavGroup` | |
198
+ | `MobileNavLinkRenderer` (type) | `MobileNavModel` | |
199
+ | `SidebarSection.mobileTab` | `SidebarSection.mobile?: false \| NavSectionMobile` — an OBJECT (`{ tab: true, label?, icon? }`), or `false` to hide the section | `'tab' \| 'more' \| 'hidden'` is `SidebarItem.mobile` (`NavMobilePlacement`, `true` ≡ `'tab'`, `false` ≡ `'hidden'`) — a different prop on a different type |
113
200
 
114
201
  A consumer with no router needs no migration — `href` + `onClick` still work.
115
202
 
@@ -130,20 +217,21 @@ never `search: true`.
130
217
 
131
218
  The largest delta in the 1.x line. No shims were shipped.
132
219
 
133
- | Removed / renamed | Replacement | Note |
134
- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
135
- | `ChartHoverSync`, `ChartHoverSyncProps` | `globalCursorStore` / `createCursorStore` / `useChartCursor` / `useCursorState` — **no provider** | `ChartCursorScope` now _isolates_ a subtree; it is the inverse of the old provider, not a rename |
136
- | `HoverContext`, `HoverCtx`, `useHoverSync`, `DEFAULT_NO_OP_SET_HOVER` | `useChartCursor`, `useCursorState`, `CursorState`, `CursorStore` | context → `useSyncExternalStore` |
137
- | `ResponsiveChart`, `ResponsiveChartProps` | `CartesianChart` / `CartesianChartProps` (+ `autoMargin`, `PlotContext`, `PlotRect`) | now mandatory for every single-plot cartesian chart, enforced by `basalt/hand-rolled-plot` |
138
- | `ChartTooltip` (tip-based), `useChartTooltip`, `useTooltipStyles` | `ChartTooltipFloat` + the `tooltip: CartesianTooltipConfig` prop | portal / flip / clamp done once |
139
- | `BarsAxisConfig` | `AxisConfig` + `resolveAxisDomain` | |
140
- | `ZonedLineTooltipLabel` | `tooltip` config on the kind | |
141
- | `ZonedLine`/`MultiLine`: `yDomain`, `yAutoPad`, `yAutoMaxFloor`, `yAutoMinCeil`, `numTicksY`, `formatYTick` | one `y?: AxisConfig<T>` | `y` is **optional** where `yDomain` was required |
142
- | `ZonedLine`/`MultiLine`: `formatValue`, `tooltipLabel`, `renderExtraTooltipRows` | `tooltip?: CartesianTooltipConfig` | **not** `y` — these are tooltip concerns |
143
- | `ZonedLine`/`MultiLine`: `numTicksX` | `xTicks` | `xZones` added alongside |
144
- | `Bars`: `formatValue`, `hideBarTooltipRows`, `leftAxis`, `rightAxis`, `marginLeft`, `numTicksX`, `tooltipLabel`, `renderExtraTooltipRows`, `renderPrefixTooltipRows` | `y` / `y2` (`AxisConfig`), `tooltip`, `SeriesStyle.tooltip`, measured `autoMargin` | passing `y2` is what makes a chart dual-axis; `chartMargin({ rightAxis })` is gone |
145
- | `StackedArea`: `formatValue`, `numTicksX`, `numTicksY`, `yAutoMaxFloor`, `yLabel` | `y`, `tooltip`, `xTicks` | |
146
- | `Heatmap.width` | measures itself; takes `height` / `aspectRatio` / `fill` | |
220
+ | Removed / renamed | Replacement | Note |
221
+ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
222
+ | `ChartHoverSync`, `ChartHoverSyncProps` | `globalCursorStore` / `createCursorStore` / `useChartCursor` / `useCursorState` — **no provider** | `ChartCursorScope` now _isolates_ a subtree; it is the inverse of the old provider, not a rename |
223
+ | `HoverContext`, `HoverCtx`, `useHoverSync`, `DEFAULT_NO_OP_SET_HOVER` | `useChartCursor`, `useCursorState`, `CursorState`, `CursorStore` | context → `useSyncExternalStore` |
224
+ | `ResponsiveChart`, `ResponsiveChartProps` | `CartesianChart` / `CartesianChartProps` (+ `autoMargin`, `PlotContext`, `PlotRect`) | now mandatory for every single-plot cartesian chart, enforced by `basalt/hand-rolled-plot` |
225
+ | `ChartTooltip` (tip-based), `useChartTooltip`, `useTooltipStyles` | `ChartTooltipFloat` + the `tooltip: CartesianTooltipConfig` prop | portal / flip / clamp done once |
226
+ | `BarsAxisConfig` | `AxisConfig` + `resolveAxisDomain` | |
227
+ | `ZonedLineTooltipLabel` | `tooltip` config on the kind | |
228
+ | `ZonedLine`/`MultiLine`: `yDomain`, `yAutoPad`, `yAutoMaxFloor`, `yAutoMinCeil`, `numTicksY`, `formatYTick` | one `y?: AxisConfig<T>` | `y` is **optional** where `yDomain` was required |
229
+ | `ZonedLine`/`MultiLine`: `tooltipLabel`, `renderExtraTooltipRows` | `tooltip.label`, `tooltip.extraRows` (`CartesianTooltipConfig`) | there is no `appendRows`; the field is `extraRows` |
230
+ | `ZonedLine`/`MultiLine`: `formatValue` | **`y.format`** — or per-series `ChartSeries.formatValue` for one row | `CartesianTooltipConfig` has no value formatter: the axis format IS the tooltip value format |
231
+ | `ZonedLine`/`MultiLine`: `numTicksX` | `xTicks` | `xZones` added alongside |
232
+ | `Bars`: `formatValue`, `hideBarTooltipRows`, `leftAxis`, `rightAxis`, `marginLeft`, `numTicksX`, `tooltipLabel`, `renderExtraTooltipRows`, `renderPrefixTooltipRows` | `y` / `y2` (`AxisConfig`), `xTicks`, `tooltip.{label,extraRows,prependRows}`, per-bar `BarsBar.formatValue` / `.tooltip`, measured `autoMargin` + `margin` | passing `y2` is what makes a chart dual-axis. `chartMargin({ rightAxis })` is **not** removed — it is still exported from `basalt-ui` and `basalt-ui/charts`; it is simply no longer needed, since the right gutter follows from measurement |
233
+ | `StackedArea`: `formatValue`, `numTicksX`, `numTicksY`, `yAutoMaxFloor`, `yLabel` | `y` (incl. `y.format`), `xTicks`; per-series `ChartSeries.formatValue` | `StackedAreaProps` has **no** `tooltip` prop — its tooltip is entirely derived from `series` |
234
+ | `Heatmap.width` | measures itself; takes `height` / `aspectRatio` / `fill` | |
147
235
 
148
236
  ## 1.12.0 — agent-chat, behaviour only
149
237
 
@@ -176,6 +264,12 @@ Names that have no 1.x equivalent: the `--chart-blue-1..8` sequential ramp (1.x
176
264
  `--vx-fill-*` only), the default font stacks as a token (they live in `styles.css`), `purple` as a
177
265
  text color, `black`, `blue-400`/`green-400`.
178
266
 
267
+ **The typefaces changed at 1.0, not just their delivery.** 0.4.2 shipped Instrument Sans; 1.x ships
268
+ Nunito Sans (body) + Hubot Sans (condensed headings), mono unchanged at JetBrains Mono. `fonts:css`
269
+ emits the 1.x stacks — for a 0.4.2 migrant that is a **rebrand**, not a restoration. It also emits
270
+ `--basalt-font-head-stretch: 88%`, tuned for Hubot Sans specifically; pointed at another face it
271
+ silently condenses it.
272
+
179
273
  ## 1.0.1 — CLI binary renamed
180
274
 
181
275
  `basalt` → **`basalt-ui`**. Never `bunx basalt`.
@@ -191,18 +285,18 @@ live in `configs/oxlint.json`, and since 1.20.0 their grace is tracked by `PLUGI
191
285
  named export beside the plugin — a test asserts it against the shipped preset in both directions, so
192
286
  deleting an entry forces the level flip in the same commit.
193
287
 
194
- | Rule | Landed | Became `error` |
195
- | ---------------------------------- | ---------------- | ---------------------------------- |
196
- | `mantine-shade-index` (guard kind) | 1.7.0 as `warn` | **1.11.0** |
197
- | `basalt/raw-scroll-container` | ≤1.2.0 as `off` | `warn` 1.10.0 → **`error` 1.13.0** |
198
- | `basalt/ai-sdk-major` | 1.10.0 as `warn` | **1.13.0** |
199
- | `basalt/agent-no-raw-usechat` | 1.10.0 as `warn` | **1.13.0** |
200
- | `basalt/agent-resume-guard` | 1.10.0 as `warn` | **1.13.0** |
201
- | `basalt/raw-size-literal` | 1.7.0 as `warn` | **1.20.0** |
202
- | `basalt/hand-rolled-plot` | 1.15.0 as `warn` | still `warn` — widened at 1.20.0 |
203
- | `basalt/chart-legend-literal` | 1.15.0 as `warn` | still `warn` — widened at 1.20.0 |
204
- | `basalt/shadow-basalt-export` | 1.20.0 as `warn` | may stay `warn` permanently |
205
- | `basalt/hand-rolled-shell` | 1.20.0 as `warn` | — |
288
+ | Rule | Landed | Became `error` |
289
+ | ---------------------------------- | ---------------- | ------------------------------------------------------------------------ |
290
+ | `mantine-shade-index` (guard kind) | 1.7.0 as `warn` | **1.11.0** |
291
+ | `basalt/raw-scroll-container` | ≤1.2.0 as `off` | `warn` 1.10.0 → **`error` 1.13.0** |
292
+ | `basalt/ai-sdk-major` | 1.10.0 as `warn` | **1.13.0** |
293
+ | `basalt/agent-no-raw-usechat` | 1.10.0 as `warn` | **1.13.0** |
294
+ | `basalt/agent-resume-guard` | 1.10.0 as `warn` | **1.13.0** |
295
+ | `basalt/raw-size-literal` | 1.7.0 as `warn` | **1.20.0** |
296
+ | `basalt/hand-rolled-plot` | 1.15.0 as `warn` | still `warn` — widened again at 1.20.1; grace restarts with the widening |
297
+ | `basalt/chart-legend-literal` | 1.15.0 as `warn` | still `warn` — widened at 1.20.0 |
298
+ | `basalt/shadow-basalt-export` | 1.20.0 as `warn` | may stay `warn` permanently — widened at 1.20.1 |
299
+ | `basalt/hand-rolled-shell` | 1.20.0 as `warn` | — |
206
300
 
207
301
  `card-with-border`, `inline-display`, `raw-html-layout`, `raw-form-control`, `raw-font-family` and
208
302
  the other original guard kinds have been `error` since before 1.2.0 — they never had a grace minor.
@@ -214,7 +308,7 @@ in the shipped preset). A config still naming `import-boundary` after 1.1.0 disa
214
308
 
215
309
  ## Deprecated, not yet removed
216
310
 
217
- The 32 camelCase `--vx-*` aliases deprecated in 1.5.0 are **still emitted at 1.20.0**.
311
+ The 32 camelCase `--vx-*` aliases deprecated in 1.5.0 are **still emitted at 1.20.1**.
218
312
  `buildPaletteCss({ legacyAliases: false })` / `tokens:css --no-legacy-aliases` opts out now; a later
219
313
  minor flips the default.
220
314
 
@@ -223,6 +317,10 @@ minor flips the default.
223
317
  - Props declared inline on a component (`function X({ a }: { a: string })`) rather than on an
224
318
  exported type are outside the diff method used here. Spot checks on the shell and charts modules
225
319
  found none, but the negative is unproven.
226
- - `AppBreadcrumbs.parentAnchor` as the replacement for `renderBreadcrumbLink` comes from the 1.19.0
227
- commit body, not from a diffable exported type — `AppBreadcrumbsProps` is not in the public export
228
- set at either tag.
320
+ - The 0.4.2 `./css` line counts in the 1.0.0 table (877 lines / 307 custom props / 19 utilities) come
321
+ from the removal commit, not from a re-read of the 0.4.2 tarball.
322
+
323
+ `AppBreadcrumbs.parentAnchor` was listed here through 1.20.0 and is now verified: it is declared on
324
+ the shipped `dist/shell/app-breadcrumbs.d.ts` (`parentAnchor?: NavAnchor`, with `parentHref` as the
325
+ no-router fallback). The type is inline on the component rather than an exported `AppBreadcrumbsProps`,
326
+ which is why the export-surface diff never saw it.
package/README.md CHANGED
@@ -193,12 +193,19 @@ and everything derives from it — the guard's scan, the seeded CI `oxfmt` globs
193
193
  exemption:
194
194
 
195
195
  ```json
196
- // package.json — `roots` is written by init; the rest are yours to add
196
+ // package.json — `roots` is written by init and backfilled by sync; the rest are yours to add
197
197
  {
198
198
  "basalt": {
199
199
  "roots": ["apps/web/src"],
200
200
  "include": ["app/manifest.json"],
201
- "profile": "tokens-only"
201
+ "profile": "tokens-only",
202
+ "exemptRules": {
203
+ "inline-display": ["agent"],
204
+ "raw-hex": {
205
+ "paths": ["public/site.webmanifest"],
206
+ "reason": "a PWA manifest theme_color MUST be a literal hex — JSON cannot reference a CSS var"
207
+ }
208
+ }
202
209
  }
203
210
  }
204
211
  ```
@@ -214,6 +221,19 @@ which would silence half the guard on any repo keeping Mantine in a different wo
214
221
  Other keys (`exempt`, `severity`, `spacingSteps`, `forbiddenAccents`, …) are documented on the
215
222
  `BasaltConfig` type.
216
223
 
224
+ **`exemptRules`** turns one kind off along a path. A pattern is a relative path
225
+ (`public/site.webmanifest`, or `src/agent` for everything under it), a glob (`*` stops at `/`, `**`
226
+ does not, and a slash-free glob is also tried against the basename, so `*.module.css` works), or a
227
+ bare path segment — the legacy shape, and the one nobody guesses, since before 1.20.1 it was the
228
+ ONLY shape and a real relative path matched nothing and said nothing. The object form records the
229
+ reason, which is what a `theme-allow` always carried and this key could not. A pattern that
230
+ suppresses nothing is now reported by a normal run and fails `--audit-allows`.
231
+
232
+ `sync` **backfills `roots`** when the key is absent — only `init` used to write it, so every
233
+ existing consumer stayed on the undeclared `src` default while `guard-scan` passed anyway. A
234
+ declared value is never overwritten; a layout it does not cover is reported as one line, because
235
+ `roots` being a subset is the normal case in a monorepo, not drift.
236
+
217
237
  Basalt-emitted LINES are skipped, not whole files. The file has to earn it first — a `.css` path,
218
238
  the `@generated basalt-ui` header verbatim on line 1, the version + invocation line on line 2 — and
219
239
  then each line does too: at brace depth 0 a selector or a self-closing comment, inside a block only
@@ -222,12 +242,26 @@ stylesheet `tokens:css` just wrote is not reported back at you, pasting the mark
222
242
  suppresses nothing, and an ordinary declaration smuggled into a sheet wearing the header is
223
243
  reported on its own line.
224
244
 
225
- **The escape hatch is scoped.** `theme-allow <rule-id> — <reason>` waives that one kind, on the
226
- reported line, on a comment-only line directly above it (the only form JSX can express), or — in
227
- CSS — from a trailing comment back over the declaration it terminates. A bare `theme-allow` still
228
- waives everything but reports `theme-allow-unscoped`. The id slot is read strictly: a word there
229
- that names no rule waives nothing, so an annotation covers exactly the ids it got right and a typo
230
- is never the blanket form.
245
+ **The escape hatch is scoped, and since 1.20.1 the two scopes are spelled apart:**
246
+
247
+ ```text
248
+ theme-allow → this node/line, EVERY rule (reports theme-allow-unscoped)
249
+ theme-allow <id>[, <id>…] [— <why>] → this node/line, those rules
250
+ theme-allow-file <id>[, <id>…] — <why> → the WHOLE FILE, those rules; a bare one waives NOTHING
251
+ "basalt:theme-allow[-file]": "<id>… — <why>" → the same two, for JSON / .webmanifest
252
+ ```
253
+
254
+ An annotation must **start** its comment (after `//`, `/*`, `<!--`, a block gutter `*`, or
255
+ whitespace) — before 1.20.1 any comment merely mentioning the token parsed as the bare blanket form,
256
+ so a file documenting its own waivers disarmed itself. Placements: the reported line, a comment-only
257
+ line directly above it (the only form JSX can express, and it reaches the first CODE line below,
258
+ walking through the rest of its comment block), or — in CSS — a trailing comment reaching back over
259
+ the declaration it terminates. The id slot is read strictly: a word there that names no rule waives
260
+ nothing, so a typo is never the blanket form.
261
+
262
+ `basalt-ui check-theme --audit-allows` reports every annotation and every `exemptRules` entry with
263
+ what it still suppresses, proved by re-running the guard with that one waiver neutralized. Exits 1
264
+ on a dead waiver, so it is usable as a CI gate.
231
265
 
232
266
  `doctor`'s `ai-major-parity` hard check fails a monorepo where workspace packages declare different
233
267
  `ai` package majors — unless the split is intentional and written down. A producer pinned to an
@@ -96,6 +96,25 @@ refuses on a commented config rather than deleting the comments.
96
96
  Run `basalt-ui doctor` afterwards to confirm the wiring took. A check that cannot RUN is reported as
97
97
  `SKIPPED` and exits non-zero on its own — "All checks passed" is only printable when every check ran.
98
98
 
99
+ ### The lefthook preset overrides YOU, not the other way round
100
+
101
+ New in 1.20.1: `doctor`'s `lefthook-preset` check hard-fails when the repo-root lefthook config
102
+ `extends` a path that does not resolve. That state is otherwise invisible — lefthook merges a
103
+ missing target into **zero** commands and exits 0, so `lefthook dump` looks clean and the repo has
104
+ no pre-commit gate with nothing anywhere saying so. It warns on a lefthook.yml wiring its own hooks,
105
+ and is n/a with no lefthook at all. `sync` reports the same seam, since it is the command an upgrade
106
+ actually runs and neither file is one basalt owns.
107
+
108
+ **An `extends` target WINS on a colliding key.** Declare `pre-commit.commands.oxfmt.run` (or
109
+ `glob:`) in your own file and the preset's value is what runs — yours is discarded silently. Only
110
+ keys the preset does not define merge in. So `env:`, `exclude:`, `skip:` and a differently-named
111
+ command of your own are yours; `run:` and `glob:` on a shipped command are not. The sanctioned seam
112
+ is therefore `BASALT_BIN`: the guard job runs `${BASALT_BIN:-bunx --no-install basalt-ui}`, and the
113
+ shell default is the whole reason the seam exists at all. `init` renders it with the resolved local
114
+ bin. `--no-install` is deliberate — a bare `bunx basalt-ui` in a repo where basalt is not resolvable
115
+ from the root silently downloads a different copy from npm and gates the commit with a version
116
+ nobody pinned.
117
+
99
118
  ## `index.html` and `public/` are scanned
100
119
 
101
120
  `check-theme` resolves `.html` / `.webmanifest` / `.json` as markup (colour kinds only), and each
@@ -133,6 +152,28 @@ they track the palette automatically. It also injects the `apple-mobile-web-app-
133
152
  `viewport-fit=cover` (skipped when the consumer's own `index.html` already declares one), and
134
153
  site-wide OG/Twitter defaults from `options.seo`.
135
154
 
155
+ **Set `colorScheme` to whatever the app passes as `defaultColorScheme`** — `'dark'` (the default),
156
+ `'light'`, `'auto'` (declares `color-scheme: light dark` and picks the pre-boot background off
157
+ `prefers-color-scheme`), or `false` (paint the background, declare no `color-scheme`).
158
+
159
+ ```ts
160
+ plugins: [react(), ...basaltAppPlugin({ name: 'Argo', colorScheme: 'auto' })]
161
+ ```
162
+
163
+ Before 1.20.1 the anti-FOUC rule emitted `html{color-scheme:dark}` **unlayered**, which outranks
164
+ Mantine's own `@layer mantine` declaration regardless of specificity — a light-scheme consumer got
165
+ dark scrollbars, dark `<select>` popups and dark date pickers permanently, with no opt-out. The rule
166
+ is now scoped to `html:not([data-mantine-color-scheme])`, so it expires the instant MantineProvider
167
+ resolves the real scheme. A mismatched `colorScheme` is therefore a flash of the wrong surface, not
168
+ a stuck one.
169
+
170
+ **The plugin hoists your `<meta charset>`.** Every injected tag defaults to `head-prepend`, which
171
+ landed ~20 tags ahead of the consumer's own encoding declaration — measured at byte 1653 on a
172
+ realistic shell, past the 1024-byte window the HTML spec gives it, with no consumer-side fix
173
+ available. The plugin now re-emits the declaration as its own first head-prepend tag (46 bytes after
174
+ the fix) and runs `order: 'pre'` so nothing else can land ahead of it. The legacy `http-equiv` form
175
+ is detected and left verbatim.
176
+
136
177
  Icons are **bring-your-own** — basalt-ui takes no `sharp`/image-processing dependency. The plugin
137
178
  expects these filenames under `public/` (or `icons.dir` if customized):
138
179
 
@@ -57,12 +57,14 @@ radial/matrix shape, is not optional — two `basalt` oxlint plugin rules
57
57
  `AxisRightNumeric`, `AxisBottomDate`, `HoverOverlay`, `Crosshair`) in a file that does not
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
- file went unpoliced. A genuinely non-single-plot shape (multi-pane, radial, matrix) declares
61
- itself with a written file declaration — a `theme-allow` that both names the rule and gives a
62
- reason, anywhere in the file (`theme-allow hand-rolled-plot — two panes over one x scale`);
63
- `DualPanel` carries the repo's only one. The file that DEFINES `CartesianChart` is exempt
64
- definitionally (detected by declaration, not by path), since a rule saying "compose X" cannot
65
- fire inside X.
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.20.1
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
66
+ exempt definitionally (detected by declaration, not by path), since a rule saying "compose X"
67
+ cannot fire inside X.
66
68
  - **`basalt/chart-legend-literal`** — a hand-written array literal passed to `ChartLegend`'s
67
69
  `items`; **since 1.20.0 also a `.map()` over a non-`series` array**, because deriving from AN
68
70
  array is not deriving from THE series. The legend must come from the same `series` the chart
@@ -93,12 +95,13 @@ ships no map kind.
93
95
  - **Want `@visx/geo` anyway?** Install it yourself — basalt doesn't pin it — and keep it under a
94
96
  `charts/` segment like every other visx import, same as any bespoke chart.
95
97
  - **Style map chrome with `--vx-*` tokens** (`VX.*`/`alpha()`) so it matches the rest of the app.
96
- - **The guard trap:** `check-theme`'s `inline-spacing` kind is a per-line regex over raw source, not
97
- an AST pass — it flags any `padding:`/`gap:`/`margin:` followed by a number in _any_ object
98
- literal, in any file. A map's pixel geometry (`fitBounds({ padding: 48 })`, marker offsets) trips
99
- it even though it isn't spacing, and hoisting the value to a module-scope const doesn't help — the
100
- regex sees the line, not the binding. Scope a `theme-allow inline-spacing — map geometry` comment
101
- to it; that's the sanctioned answer, not a bug to file.
98
+ - **The guard trap, narrowed at 1.20.1:** `check-theme`'s `inline-spacing` kind is a per-line regex
99
+ over raw source, not an AST pass. A **unitless** number now only reports inside a style-object
100
+ context (a `style=`/`sx=`/`css=` attribute, or a const whose name or type says styles), so a map's
101
+ pixel geometry — `fitBounds({ padding: 48 })`, marker offsets — no longer trips it: an options bag
102
+ is not CSS. A value carrying a unit is CSS wherever it was written and still reports. Where it
103
+ does fire on genuine map chrome, scope a `theme-allow inline-spacing — map geometry` comment to
104
+ it; that's the sanctioned answer, not a bug to file.
102
105
 
103
106
  ## Every chart has
104
107
 
@@ -208,6 +208,10 @@ axes AND together — matching the existing multi-facet `filterFn` semantics in
208
208
 
209
209
  **Filter state is URL state.** Single-select axes (category) use `createSearchParamStore`;
210
210
  multi-select axes (tags) use `createMultiSearchParamStore` — both from `basalt-ui/router-tanstack`.
211
+ That holds for the enum-shaped axes an article filter has. **It is not a general URL-state mandate**:
212
+ a route whose `validateSearch` is a real Zod object is hand-written, and `createSearchSchemaStore`
213
+ is planned, not shipped — see `basalt-router.md` → "validateSearch".
214
+
211
215
  Two stores compose on one route by spreading both `validateSearch` results:
212
216
 
213
217
  ```ts