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
@@ -65,12 +65,59 @@ function ResourcePage() {
65
65
 
66
66
  ## validateSearch
67
67
 
68
+ **Scope of the `createSearchParamStore` mandate — read this before reaching for the store.**
69
+ `createSearchParamStore` covers ONE param whose values are a string enum
70
+ (`{ key, param, values, fallback }`); `createMultiSearchParamStore` is the multi-select of the same
71
+ shape. Where that fits — a range picker, a tab, a single filter — it is **mandatory**: use it, don't
72
+ hand-roll persistence.
73
+
74
+ Where it doesn't fit, it doesn't apply. A route whose `validateSearch` is a real Zod object (10+
75
+ keys, nested shapes, cross-field defaults) cannot be expressed through the store at all — three
76
+ consumers hit that wall. **Hand-write `validateSearch` there**, per the rules below. There is no
77
+ schema-backed store: `createSearchSchemaStore` is planned and **not shipped** — do not write code
78
+ against it.
79
+
68
80
  - Always parse with the schema-function form: `(raw) => SearchSchema.parse(raw)`.
69
81
  - Default values live in the schema (`.default()`), not in the component.
70
82
  - Access via `const search = Route.useSearch()`.
71
83
  - Update via `navigate({ search: { window: 'all', from: undefined, to: undefined } })` — prefer plain
72
84
  objects over reducers to avoid `| undefined` assignability errors when the schema has defaults.
73
85
 
86
+ ### Wire the reader, or the store does nothing (1.20.1)
87
+
88
+ `validateSearch` is only half the store. It restores the value when you arrive with **no** param —
89
+ it cannot do anything about a nav link that declares the param itself, and a link written
90
+ `search: { window: '30d' }` at module scope pins the fallback on every click. That is not
91
+ hypothetical: the reference consumer adopted the store in three features, hand-rolled the
92
+ persistence in all three, and its reader had **zero** call sites. "Remember my window" had never
93
+ once worked, with every individual piece looking correct.
94
+
95
+ `store.linkSearch` is the fix, and it is on the object the factory already returns:
96
+
97
+ ```ts
98
+ const dashboardRange = createSearchParamStore({
99
+ key: 'dashboard-range',
100
+ param: 'range',
101
+ values: ['1d', '7d', '30d'] as const,
102
+ fallback: '30d',
103
+ })
104
+ // → { validateSearch, useStore, readStored, linkSearch }
105
+
106
+ link: linkOptions({ to: '/dashboard', search: dashboardRange.linkSearch })
107
+ ```
108
+
109
+ **Pass it by reference, never call it.** It is the click-time thunk
110
+ (`() => ({ [param]: readStored() ?? fallback })`); `<Link>` re-evaluates it on every click, so
111
+ arriving from outside the sub-tree restores the last selection. A value computed once at module
112
+ scope goes stale immediately. Hand-rolling the thunk over `readStored()` still works and is what
113
+ you need when the link carries other params too.
114
+
115
+ In dev, `validateSearch` warns once per store when the URL pins the fallback, a different value is
116
+ persisted, and neither public reader has ever been called — the one combination that has exactly
117
+ this cause. Silent in production, silent on a deliberate deep link.
118
+ `createMultiSearchParamStore` gets `linkSearch` but no warning: an empty array in the URL is
119
+ indistinguishable from an absent param, so a literal link there still restores correctly.
120
+
74
121
  ## loaderDeps
75
122
 
76
123
  - Define `loaderDeps` whenever the loader depends on search params. Without it, search-param changes do
@@ -163,6 +210,7 @@ closing a cycle.
163
210
  ```tsx
164
211
  import { linkOptions } from '@tanstack/react-router'
165
212
  import { defineNav, navGroup } from 'basalt-ui/router-tanstack'
213
+ import { garminWindow } from './search-params' // a createSearchParamStore
166
214
 
167
215
  const ICON = 18
168
216
 
@@ -175,7 +223,9 @@ export const NAV = defineNav({
175
223
  short: 'Garmin', // bar/menu label — keep it ≤ 10 chars
176
224
  mobile: 'tab', // give this destination its own slot on the mobile bar
177
225
  icon: <IconHeartbeat size={ICON} />,
178
- link: linkOptions({ to: '/garmin-health', search: { window: '30d' } }),
226
+ // NOT `search: { window: '30d' }` — a module-scope literal pins the fallback on
227
+ // every click and silently overrides the store. Pass the reader by reference.
228
+ link: linkOptions({ to: '/garmin-health', search: garminWindow.linkSearch }),
179
229
  },
180
230
  {
181
231
  id: 'reading',
@@ -203,9 +253,11 @@ Rules that are not stylistic:
203
253
  `label`, `short`, `icon`, `mobile`, `disabled` and `exact` ride outside it. A flat shape
204
254
  (`{ id, label, to }`) validates `to` by ASSIGNABILITY, which does no excess-property checking, so
205
255
  a typo'd metadata key would compile silently. A top-level `to:` is a compile error by design.
206
- - **`search` may be a thunk** — `search: () => ({ date: format(new Date(), 'yyyy-MM-dd') })`
207
- compiles without `as const` and re-evaluates at click time, so "today" never goes stale in a
208
- long-lived tab.
256
+ - **`search` may be a thunk, and for a store-backed param it MUST be** —
257
+ `search: () => ({ date: format(new Date(), 'yyyy-MM-dd') })` compiles without `as const` and
258
+ re-evaluates at click time, so "today" never goes stale in a long-lived tab. A param backed by
259
+ `createSearchParamStore` takes the store's own `linkSearch` (see "validateSearch" above); a
260
+ literal there is the one shape that defeats the store.
209
261
  - **Nesting is `children`**, and nested destinations are indexed by id just like top-level ones.
210
262
  - **`NavItemMeta`**'s `mobile` field places the destination on the bar: `'tab'` (own slot),
211
263
  `'more'` (the default — reachable through the More overlay) or `'hidden'`. A group claims a slot
@@ -237,7 +289,7 @@ unreadable in a 56px tab.
237
289
  `router.navigate()` or `redirect()` instead of restating the route and its default search:
238
290
 
239
291
  ```ts
240
- throw redirect(navTarget(NAV, 'garmin')) // exactly { to: '/garmin-health', search: { window: '30d' } }
292
+ throw redirect(navTarget(NAV, 'garmin')) // exactly { to: '/garmin-health', search: garminWindow.linkSearch }
241
293
  ```
242
294
 
243
295
  **`flattenNav`** walks the definition depth-first (parent, then children), tagging each destination
@@ -24,9 +24,10 @@ Pick the right home for each kind of state — don't dump everything into one st
24
24
  `--vx-*` tokens via CSS; reading it any other way breaks scheme reactivity and trips `basalt-ui check-theme`.
25
25
  - **UI preferences that must survive navigation but aren't URL-worthy** (sidebar collapsed, panel
26
26
  layout, draft filters) → `createPersistedState` from `basalt-ui/state` — the framework's own
27
- versioned localStorage primitive (see below). `BasaltShell` already persists its own collapse state
28
- internally; use `createPersistedState` for app-level preferences. Reach for a third-party store
29
- only when complex cross-component state genuinely warrants it (see escape hatch below).
27
+ versioned localStorage primitive (see below). `BasaltShell` persists its own collapse state
28
+ through that same primitive since 1.20.1; use `createPersistedState` for app-level preferences.
29
+ Reach for a third-party store only when complex cross-component state genuinely warrants it (see
30
+ escape hatch below).
30
31
 
31
32
  ## useOnlineStatus — SSR-safe online/offline hook
32
33
 
@@ -66,6 +67,23 @@ const [layout, setLayout] = usePanelLayout()
66
67
  Keys are namespaced `basalt:<key>` automatically and never collide with the theme-scheme guard.
67
68
  Pass a `schema` (Standard Schema) to validate persisted values and fall back to `initial` on mismatch.
68
69
 
70
+ ### Mirroring `BasaltShell`'s collapse (changed key at 1.20.1)
71
+
72
+ `BasaltShell` used `@mantine/hooks`' `useLocalStorage` while `createPersistedState` was the
73
+ documented house API — so a consumer reading the raw key was complying with the shipped component,
74
+ not drifting from it. It now stores through `createPersistedState`, which makes it SSR-safe and
75
+ moves the key:
76
+
77
+ ```ts
78
+ import { readPersistedValue } from 'basalt-ui/state'
79
+
80
+ // 1.20.1: localStorage['basalt:<storageKey>'] === '{"v":1,"value":true}'
81
+ const collapsed = readPersistedValue('basalt-sidebar-collapsed', 1)
82
+ ```
83
+
84
+ A one-time migration adopts the raw pre-1.20.1 value, so an upgrade does not silently re-expand
85
+ every sidebar. It is a bridge, not a format — do not write against it.
86
+
69
87
  ## Zustand escape hatch (complex cross-component stores)
70
88
 
71
89
  Reach for Zustand only when `createPersistedState` is insufficient — typically when multiple
@@ -43,33 +43,76 @@ The scan reaches slightly beyond `roots`: each root's PARENT contributes its `in
43
43
  meta and a webmanifest's `background_color` actually live. `.json` is never blanket-scanned — name a
44
44
  design-surface JSON in `basalt.include`, the only route to one.
45
45
 
46
- ## `theme-allow` — the 1.20.0 contract
46
+ ## `theme-allow` — the 1.20.1 contract
47
47
 
48
- Scope it. `theme-allow <rule-id> — <reason>` waives that ONE kind; the id is a guard kind
49
- (`raw-surface`, `inline-spacing`, …) or an oxlint plugin rule (`hand-rolled-plot`,
50
- `raw-scroll-container`, …), with or without the `basalt/` prefix.
48
+ The full grammar, in both engines:
49
+
50
+ ```text
51
+ theme-allow → this node/line, EVERY rule (reports theme-allow-unscoped)
52
+ theme-allow <id>[, <id>…] [— <why>] → this node/line, those rules (unscoped without a why)
53
+ theme-allow-file <id>[, <id>…] — <why> → the WHOLE FILE, those rules; a bare one waives NOTHING
54
+ "basalt:theme-allow[-file]": "<id>… — <why>" → the same two, for JSON / .webmanifest
55
+ ```
56
+
57
+ An id is a guard kind (`raw-surface`, `inline-spacing`, …) or an oxlint plugin rule
58
+ (`hand-rolled-plot`, `raw-scroll-container`, …), with or without the `basalt/` prefix.
51
59
 
52
60
  ```tsx
53
61
  // theme-allow raw-surface — third-party widget needs a literal corner
54
62
  <Widget style={{ borderRadius: 3 }} />
55
63
  ```
56
64
 
57
- - **A bare `theme-allow` still waives everything**, so no upgrade takes a build down over comment
58
- placement — but it now reports `theme-allow-unscoped` (`warn` for one minor, then `error`).
59
- Rescope the ones you have; that is the whole migration.
65
+ - **An annotation must START its comment** — after `//`, `/*`, `<!--`, a block-comment gutter `*`,
66
+ or nothing but whitespace. Before 1.20.1 any comment that merely _mentioned_ the token parsed as
67
+ the bare blanket form and switched every rule off on the line below: linewatch documented its own
68
+ waivers in a docblock and thereby disarmed the file. Prose about the escape hatch no longer waives
69
+ anything; everything a consumer actually writes still qualifies.
70
+ - **File scope is spelled, not inferred.** `theme-allow-file <id>… — <why>`, anywhere in the file.
71
+ Plain `theme-allow` is the node/line waiver in both engines. At 1.20.0 the two halves of the
72
+ contract intersected at exactly one legal shape and that shape was whole-file — naming a rule and
73
+ giving a reason was read as a file declaration, which is why per-node scoping did not actually
74
+ ship. **This is the consumer break in 1.20.1**: move each existing file declaration one word.
75
+ - **A bare `theme-allow` still waives everything on its placement** but reports
76
+ `theme-allow-unscoped` (`warn` for one minor, then `error`). A bare `theme-allow-file` waives
77
+ nothing at all — whole-file blanket immunity off one unnamed comment is the thing this contract
78
+ exists to price.
60
79
  - **Spell the id right — a typo waives nothing.** A word in the id slot that names no rule is
61
80
  recorded as unknown and suppresses nothing; the annotation covers exactly the ids it got right.
62
- Only a genuinely bare `theme-allow` is the blanket form. Because the id slot is read strictly, a
63
- prose reason has to be introduced by a separator: `theme-allow: <why>` or
64
- `theme-allow <id> — <why>`, never `theme-allow <why>`.
81
+ Because the id slot is read strictly, a prose reason has to be introduced by a separator:
82
+ `theme-allow: <why>` or `theme-allow <id> — <why>`, never `theme-allow <why>`.
83
+ - **JSON and `.webmanifest` get a member key**, since they have been scanned since 1.20.0 and cannot
84
+ hold a comment — their findings were unwaivable and the printed remedy prescribed something
85
+ impossible. `"basalt:theme-allow-file": "raw-hex — a PWA manifest theme_color must be a literal"`
86
+ carries the same grammar. For a manifest the first remedy is `basaltAppPlugin`, not a waiver: a
87
+ hand-copied hex drifts from the palette.
65
88
  - **Three placements work and both engines agree on all three**: the reported line, a comment-ONLY
66
89
  line directly above it (the only form JSX can express — the reported line is usually a multi-line
67
90
  opening tag or a `{expr}` child), and in CSS a trailing annotation reaching back over the
68
91
  declaration it terminates. The third is what survives the shipped `oxfmt` reflowing a long
69
92
  `background-color` so the hex lands ABOVE the comment.
70
- - **`basalt/hand-rolled-plot` no longer grants whole-file immunity** off whichever comment happened
71
- to sit on the first assembly node. Every node is waived on its own; a file-scoped exception needs
72
- a written declaration that both names the rule and gives a reason, anywhere in the file.
93
+ - **A comment-only annotation reaches the first CODE line below it**, walking through the rest of
94
+ its own comment block. A wrapped reason, or a docblock's `*/`, used to absorb the waiver so the
95
+ natural shape silently waived nothing — argo hit that three times in one upgrade. A blank line
96
+ still ends the block; that is how you say "this comment is not about the next statement".
97
+ - **`basalt/hand-rolled-plot` grants whole-file immunity only through `theme-allow-file`.** Every
98
+ assembly node is otherwise waived on its own.
99
+
100
+ ### Audit them: `basalt-ui check-theme --audit-allows`
101
+
102
+ Reports instead of scanning. For every annotation and every `basalt.exemptRules` entry it re-runs
103
+ the guard with that one occurrence neutralized, so the verdict comes from the scan rather than from
104
+ parsing the waiver's text:
105
+
106
+ ```text
107
+ src/shell/x.tsx:42 suppresses raw-surface@43
108
+ 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
110
+ raw-hex: "site.webmanifest" suppresses findings in public/site.webmanifest — <the recorded reason>
111
+ ```
112
+
113
+ It closes with a `live / dead / outside reach / unaccountable` tally and **exits 1 on a dead
114
+ waiver**, so it wires into CI. A waiver that stopped covering anything is not harmless: it silently
115
+ covers the next real finding on that line. Basalt's own source had one; it is deleted.
73
116
 
74
117
  ### New in 1.20.0 — five guard kinds, all `warn` for one minor
75
118
 
@@ -91,9 +134,23 @@ Two new oxlint rules ship at `warn` alongside them: **`basalt/shadow-basalt-expo
91
134
  Round 4 swept seven consumer repos: every gate green, and ~15 independently re-rolled copies of
92
135
  components basalt already ships (`StatCard` in 4 of 4 apps, `EmptyState`, a 266-line hand-rolled
93
136
  `AppShell`). A fork written by a token-fluent author uses exactly the right tokens, so no palette
94
- guard can ever see it. **`basalt/shadow-basalt-export` is the cheap detector** — it warns when a
95
- local component's name collides with a live basalt export, read from the real shipped barrel. The
96
- habit it stands in for: check whether basalt already ships the thing before building it.
137
+ guard can ever see it. **`basalt/shadow-basalt-export` is a cheap detector, not a safety net** — the
138
+ rule says so in its own message: it is a **tripwire, not coverage**, and silence is not evidence that
139
+ nothing is forked.
140
+
141
+ - **Exact name match only** — the one limit that is structural. Round 5 confirmed the miss rate:
142
+ linewatch's forks are named `Cell` and `Box`, rb's is `Stat`. Rename the fork and the rule goes
143
+ quiet, which is what a fork's author naturally does.
144
+ - **Scope: everything importable.** Since 1.20.1 it reads all nine published barrels, not just
145
+ `dist/index.d.ts` — so the `basalt-ui/charts` layer, which is where forks actually live, is finally
146
+ covered. Names a barrel merely re-exports from a third party are skipped: `./charts` passes
147
+ `Bar` / `Line` / `Pie` straight through from `@visx/shape`, and a local `Bar` is not a fork of
148
+ anything basalt wrote.
149
+
150
+ So the renamed majority goes unreported. **Detection does not substitute for expressiveness**: a
151
+ composite that cannot express a common case gets routed around by compliant-looking code, and a
152
+ name-collision rule only catches the authors who did not bother to rename. The habit it stands in
153
+ for is the actual control: check whether basalt already ships the thing before building it.
97
154
 
98
155
  ## Filled surfaces — the fill band
99
156
 
@@ -120,7 +120,10 @@ Lint wires as `oxlint . && basalt-ui check-theme` (seeded as the `lint:basalt` s
120
120
  guard is the teeth behind the token doctrine. Before writing any composite, check whether basalt
121
121
  already ships it: round 4 found ~15 forked copies of shipped components across seven repos, all
122
122
  passing every gate, because a fork by a token-fluent author uses exactly the right tokens.
123
- `basalt/shadow-basalt-export` warns on the name collision; nothing catches the rest.
123
+ `basalt/shadow-basalt-export` reads all nine published barrels since 1.20.1 (the charts layer
124
+ included), but it warns only on an **exact** name collision — round 5 found the renamed forks
125
+ (`Cell`, `Box`, `Stat`) sail straight past it. It is a tripwire, not coverage; nothing catches the
126
+ rest. Ask before you build; do not wait to be told.
124
127
 
125
128
  The consumer series file (`<first basalt.root>/lib/series.ts` by default — so `src/lib/series.ts` on
126
129
  a plain app, `apps/web/src/lib/series.ts` on a monorepo; override via `basalt.seriesModulePath`) is
@@ -210,9 +210,11 @@ import { ChartCursorScope } from 'basalt-ui/charts'
210
210
  `AxisLeftNumeric` / `AxisRightNumeric` / `AxisBottomDate` (escape via `theme-allow`).
211
211
  - **No hand-rolled plot assembly.** `basalt/hand-rolled-plot` fails the build on a chart-assembly
212
212
  primitive (`AxisLeftNumeric`, `AxisRightNumeric`, `AxisBottomDate`, `HoverOverlay`, `Crosshair`)
213
- rendered in a file that doesn't compose `CartesianChart`. Escape: a `theme-allow` comment on the
214
- first such site — how a genuinely non-single-plot shape (`DualPanel`, `Heatmap`, `Donut`) declares
215
- itself. The file that defines `CartesianChart` is exempt definitionally.
213
+ rendered in a file that doesn't compose `CartesianChart`, per NODE. Escape:
214
+ `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.20.1 spelling and it is required. The file that defines
217
+ `CartesianChart` is exempt definitionally.
216
218
  - **No hand-authored legends.** `basalt/chart-legend-literal` fails the build on a hand-written
217
219
  array literal passed to `ChartLegend`'s `items` — derive it from the same `series` the chart draws
218
220
  (`deriveLegend`, or let `ChartFrame`/`CartesianChart` do it) so it can't name a series the plot no
@@ -2,8 +2,9 @@
2
2
 
3
3
  ## basalt-ui (managed — do not hand-edit)
4
4
 
5
- Scaffolded by `bunx basalt-ui init` and refreshed by `bunx basalt-ui sync` (run it after a basalt-ui
6
- upgrade; `basalt-ui sync --check` gates drift in CI). This block is framework-owned — edit `DESIGN.md`
5
+ Scaffolded by `bunx basalt-ui init` (the one command that legitimately predates the install) and
6
+ refreshed by the locally installed `basalt-ui sync` after every upgrade; `basalt-ui sync --check`
7
+ gates drift in CI. This block is framework-owned — edit `DESIGN.md`
7
8
  or the `basalt-*` rules instead; manual changes here are overwritten on the next sync.
8
9
 
9
10
  **Stack:** React 19 + Mantine v9, themed by `basalt-ui` (`BasaltProvider` + `createBasaltTheme`).
@@ -18,9 +19,14 @@ code must follow) — never import `@visx/*` outside a `charts/` directory (oxli
18
19
  Toolchain is oxlint + oxfmt (no ESLint/Biome/Prettier) and `basalt-ui check-theme` guards the
19
20
  palette. Runtime is Bun.
20
21
 
21
- **Before guessing an import, check the installed package's machine docs**:
22
- `node_modules/basalt-ui/llms.txt` (per-subpath import map), `node_modules/basalt-ui/AGENTS.md`, or
23
- run `bunx basalt-ui info --json`.
22
+ **Before guessing an import, read the installed package's machine docs — `llms.txt` (per-subpath
23
+ import map) and `AGENTS.md`, at the install directory.** That is `./node_modules/basalt-ui` only on
24
+ a single-package app. In a workspace, basalt resolves under the package that depends on it
25
+ (`packages/<name>/node_modules/basalt-ui`), and the repo root may have no copy at all. Run
26
+ `basalt-ui doctor` — its `basalt-resolves` line prints the resolved install dir and version; read
27
+ the two files there. Invoke the CLI through the **locally installed** bin (the `lint:basalt` script
28
+ seeded by `init` shows the path); `bunx basalt-ui` fetches a second copy from npm and can answer
29
+ for a different version than the one you are building against.
24
30
 
25
31
  **DESIGN.md is law.** `./DESIGN.md` (imported below) records this app's palette identity and series
26
32
  dictionary. Precedence: **DESIGN.md > `basalt-*` rules > skills.** When building or restyling any
@@ -2,19 +2,32 @@
2
2
  # `basalt-ui init` seeds a lefthook.yml that references this file:
3
3
  # extends:
4
4
  # - node_modules/basalt-ui/configs/lefthook.yml
5
- # lefthook merges these commands with the consumer's own, so this preset auto-updates
6
- # with the package. Runs oxlint + oxfmt on staged files and the theme guard on the whole
7
- # tree, pre-commit. Assumes oxlint/oxfmt are installed.
5
+ # so this preset auto-updates with the package. Runs oxlint + oxfmt on staged files and the theme
6
+ # guard on the whole tree, pre-commit. Assumes oxlint/oxfmt are installed.
7
+ #
8
+ # ── HOW OVERRIDING ACTUALLY WORKS (read before trying) ───────────────────────────────────────────
9
+ # An `extends` target WINS over your own lefthook.yml on a colliding key. Verified with
10
+ # `lefthook dump`: declare `pre-commit.commands.oxfmt.run` (or `glob:`) in your file and the
11
+ # preset's value is what runs — yours is discarded silently, with no warning. Only keys this preset
12
+ # does NOT define merge in from yours. So:
13
+ # • `env:`, `exclude:`, `skip:` and any command name not used here — yours, they merge.
14
+ # • `run:` and `glob:` on a command below — NOT overridable. Do not try.
15
+ # The sanctioned seams are therefore:
16
+ # • `BASALT_BIN` (below) — set it via `env:` to point the guard at a specific CLI.
17
+ # • `exclude:` — carve files out of a glob you cannot narrow.
18
+ # • a differently-named command of your own, plus `skip: true` on the one here you are replacing.
8
19
  #
9
20
  # It does NOT assume your `.oxlintrc.json` extends the shipped preset — that assumption was
10
21
  # false for exactly the repos this seed exists for, since `init` KEEPS an existing oxlint
11
- # config. `basalt-ui doctor` now checks the extends and hard-fails when it is missing; run it
12
- # in CI (the seeded check.yml does) rather than trusting a comment nobody reads.
22
+ # config. `basalt-ui doctor` now checks the extends and hard-fails when it is missing OR when it
23
+ # points at a path that does not resolve; run it in CI (the seeded check.yml does) rather than
24
+ # trusting a comment nobody reads.
13
25
  #
14
26
  # `init` seeds the path RESOLVED from where basalt actually installed. Under bun's isolated
15
27
  # linker basalt lives beside the package that depends on it, not at the repo root, and a
16
- # root-relative `extends` there resolves to nothing — silently, because lefthook merges an
17
- # empty file without complaint.
28
+ # root-relative `extends` there resolves to nothing — silently, because lefthook merges a missing
29
+ # file into ZERO commands and exits 0. `basalt-ui doctor`'s `lefthook-preset` check is the only
30
+ # thing that can see that state; nothing else errors.
18
31
  pre-commit:
19
32
  parallel: true
20
33
  commands:
@@ -24,9 +37,20 @@ pre-commit:
24
37
  # `css` is load-bearing, not tidiness: oxfmt formats stylesheets, so without it a CSS-only
25
38
  # commit passes the hook and then fails the same check in CI. `oxlint` above deliberately
26
39
  # stops at JS — it has nothing to say about CSS.
40
+ #
41
+ # SOURCE EXTENSIONS ONLY, deliberately. This glob used to include `json,md,yml,yaml`, which
42
+ # reached every staged file at the repo ROOT — and oxfmt pads markdown table separators, so it
43
+ # blocked commits on a consumer's CLAUDE.md and docs/ over a house style basalt has no opinion
44
+ # about, in files their own `format` script does not own. Since you cannot narrow this glob
45
+ # (see the override note above), the preset has to be the one that is narrow. Markdown, YAML
46
+ # and JSON stay yours: add them to your own `format` script if you want them formatted.
47
+ #
48
+ # `--no-error-on-unmatched-pattern` covers the case where every staged file is excluded by an
49
+ # ignore file: oxfmt exits 2 on "Expected at least one target file", which failed a docs-only
50
+ # commit for any consumer carrying a .prettierignore — and, again, they could not add the flag.
27
51
  oxfmt:
28
- glob: '*.{ts,tsx,js,jsx,css,json,md,yml,yaml}'
29
- run: bunx oxfmt --check {staged_files}
52
+ glob: '*.{ts,tsx,js,jsx,css}'
53
+ run: bunx oxfmt --check --no-error-on-unmatched-pattern {staged_files}
30
54
 
31
55
  # NO glob, deliberately. This command scans the configured `basalt.roots` — it never reads
32
56
  # {staged_files} — so any glob here is a gate on WHETHER the guard runs, decided by the file
@@ -38,14 +62,23 @@ pre-commit:
38
62
  # Without this, a consumer accumulates violations indefinitely while `check-theme` is never
39
63
  # invoked, and the kinds it never ran are indistinguishable from the kinds that found nothing.
40
64
  #
65
+ # `$BASALT_BIN` is the one sanctioned override seam here, because `run:` itself is not
66
+ # overridable (see the note at the top). It defaults to `bunx --no-install basalt-ui`:
67
+ # • `--no-install` because a plain `bunx basalt-ui` in a repo where basalt is not resolvable
68
+ # from the ROOT (bun's isolated linker puts it beside the package that depends on it)
69
+ # silently downloads a DIFFERENT copy from npm and gates the commit with a version nobody
70
+ # pinned. With the flag it fails loudly instead, which is the answer you can act on.
71
+ # • point it at the local bin to fix that, exactly as the seeded check.yml does:
72
+ # pre-commit:
73
+ # commands:
74
+ # check-theme:
75
+ # env: { BASALT_BIN: ./packages/web/node_modules/.bin/basalt-ui }
76
+ #
41
77
  # Lefthook runs commands at the REPO ROOT, while in a monorepo the `basalt` config and the
42
78
  # manifest live in a package below it — verified to produce `0 files scanned`, i.e. following
43
79
  # this preset installed a gate that never gated. `check-theme` now relocates to the single
44
80
  # workspace package carrying a basalt config and says so; with more than one, set BASALT_CWD
45
- # in your own lefthook.yml to name the package:
46
- # pre-commit:
47
- # commands:
48
- # check-theme:
49
- # env: { BASALT_CWD: apps/web }
81
+ # in the same `env:` block to name the package:
82
+ # env: { BASALT_CWD: apps/web }
50
83
  check-theme:
51
- run: bunx basalt-ui check-theme
84
+ run: ${BASALT_BIN:-bunx --no-install basalt-ui} check-theme