basalt-ui 1.20.0 → 1.22.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.
- package/AGENTS.md +11 -5
- package/CHANGELOG.md +33 -0
- package/MIGRATING.md +231 -50
- package/README.md +57 -10
- package/agent/rules/basalt-app.md +49 -2
- package/agent/rules/basalt-charts.md +15 -12
- package/agent/rules/basalt-content.md +4 -0
- package/agent/rules/basalt-router.md +57 -5
- package/agent/rules/basalt-state.md +21 -3
- package/agent/rules/basalt-tokens.md +78 -16
- package/agent/skills/basalt-app/SKILL.md +5 -1
- package/agent/skills/basalt-charts/SKILL.md +5 -3
- package/agent/templates/CLAUDE-block.md.tpl +11 -5
- package/configs/lefthook.yml +48 -15
- package/configs/oxlint-plugin.js +218 -69
- package/configs/oxlint-plugin.test.ts +331 -9
- package/dist/charts/kinds/Bars.d.ts +1 -1
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.d.ts +1 -1
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.d.ts +1 -1
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.d.ts +1 -1
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.d.ts +1 -1
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.d.ts +1 -1
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/cli/index.d.ts +55 -15
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +521 -71
- package/dist/cli/waiver-audit.test.d.ts +2 -0
- package/dist/cli/waiver-audit.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts +114 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +216 -33
- package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
- package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.js +4 -1
- package/dist/router-tanstack/search-param-store.d.ts +29 -5
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-param-store.js +31 -3
- package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
- package/dist/shell/index.d.ts +12 -5
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +24 -6
- package/dist/tokens/index.d.ts +3 -1
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +5 -1
- package/dist/tokens/palette.d.ts +2 -2
- package/dist/tokens/palette.js +2 -2
- package/dist/tokens/text-scale.test.d.ts +2 -0
- package/dist/tokens/text-scale.test.d.ts.map +1 -0
- package/dist/tokens.css +4 -2
- package/dist/vite.d.ts +28 -3
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +75 -26
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/kinds/Bars.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +1 -1
- package/src/charts/kinds/DualPanel.tsx +2 -2
- package/src/charts/kinds/Heatmap.tsx +1 -1
- package/src/charts/kinds/MultiLine.tsx +1 -1
- package/src/charts/kinds/StackedArea.tsx +1 -1
- package/src/charts/kinds/ZonedLine.tsx +1 -1
- package/src/cli/check-theme.test.ts +35 -0
- package/src/cli/help.test.ts +5 -1
- package/src/cli/index.ts +978 -133
- package/src/cli/init-sync.test.ts +2 -0
- package/src/cli/template-ledger.test.ts +1 -1
- package/src/cli/toolchain-wiring.test.ts +352 -1
- package/src/cli/waiver-audit.test.ts +211 -0
- package/src/guard/check-source.test.ts +638 -1
- package/src/guard/index.ts +556 -63
- package/src/router-tanstack/multi-search-param-store.ts +16 -1
- package/src/router-tanstack/search-param-store.test.ts +135 -0
- package/src/router-tanstack/search-param-store.ts +90 -8
- package/src/shell/app-mobile-nav.tsx +1 -1
- package/src/shell/index.test.tsx +52 -0
- package/src/shell/index.tsx +64 -13
- package/src/tokens/index.ts +24 -1
- package/src/tokens/palette.ts +2 -2
- package/src/tokens/text-scale.test.ts +51 -0
- package/src/vite.test.ts +158 -6
- package/src/vite.ts +176 -34
|
@@ -96,6 +96,30 @@ 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
|
+
`doctor`'s `lefthook-preset` check asks whether a pre-commit gate EXISTS — not whether your config
|
|
102
|
+
contains the extends string. Since 1.21.1 it asks `lefthook dump`, which resolves `extends`,
|
|
103
|
+
`include` and per-command `root:`. **Wiring the jobs yourself passes**: linewatch runs all three with
|
|
104
|
+
`root: 'web/'` precisely because `extends` merges commands _without_ their working directory, and the
|
|
105
|
+
old text match warned there and prescribed a change that would have broken it.
|
|
106
|
+
|
|
107
|
+
A broken `extends` target is still a hard fail — lefthook merges a missing target into **zero**
|
|
108
|
+
commands and exits 0, so `lefthook dump` looks clean and the repo has no gate with nothing saying so.
|
|
109
|
+
A provably absent gate is a warn; a repo where `lefthook dump` could not run gets an advisory warning
|
|
110
|
+
naming what it could not see. n/a with no lefthook at all. `sync` reports the same seam, since it is
|
|
111
|
+
the command an upgrade actually runs and neither file is one basalt owns.
|
|
112
|
+
|
|
113
|
+
**An `extends` target WINS on a colliding key.** Declare `pre-commit.commands.oxfmt.run` (or
|
|
114
|
+
`glob:`) in your own file and the preset's value is what runs — yours is discarded silently. Only
|
|
115
|
+
keys the preset does not define merge in. So `env:`, `exclude:`, `skip:` and a differently-named
|
|
116
|
+
command of your own are yours; `run:` and `glob:` on a shipped command are not. The sanctioned seam
|
|
117
|
+
is therefore `BASALT_BIN`: the guard job runs `${BASALT_BIN:-bunx --no-install basalt-ui}`, and the
|
|
118
|
+
shell default is the whole reason the seam exists at all. `init` renders it with the resolved local
|
|
119
|
+
bin. `--no-install` is deliberate — a bare `bunx basalt-ui` in a repo where basalt is not resolvable
|
|
120
|
+
from the root silently downloads a different copy from npm and gates the commit with a version
|
|
121
|
+
nobody pinned.
|
|
122
|
+
|
|
99
123
|
## `index.html` and `public/` are scanned
|
|
100
124
|
|
|
101
125
|
`check-theme` resolves `.html` / `.webmanifest` / `.json` as markup (colour kinds only), and each
|
|
@@ -133,6 +157,28 @@ they track the palette automatically. It also injects the `apple-mobile-web-app-
|
|
|
133
157
|
`viewport-fit=cover` (skipped when the consumer's own `index.html` already declares one), and
|
|
134
158
|
site-wide OG/Twitter defaults from `options.seo`.
|
|
135
159
|
|
|
160
|
+
**Set `colorScheme` to whatever the app passes as `defaultColorScheme`** — `'dark'` (the default),
|
|
161
|
+
`'light'`, `'auto'` (declares `color-scheme: light dark` and picks the pre-boot background off
|
|
162
|
+
`prefers-color-scheme`), or `false` (paint the background, declare no `color-scheme`).
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
plugins: [react(), ...basaltAppPlugin({ name: 'Argo', colorScheme: 'auto' })]
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Before 1.21.0 the anti-FOUC rule emitted `html{color-scheme:dark}` **unlayered**, which outranks
|
|
169
|
+
Mantine's own `@layer mantine` declaration regardless of specificity — a light-scheme consumer got
|
|
170
|
+
dark scrollbars, dark `<select>` popups and dark date pickers permanently, with no opt-out. The rule
|
|
171
|
+
is now scoped to `html:not([data-mantine-color-scheme])`, so it expires the instant MantineProvider
|
|
172
|
+
resolves the real scheme. A mismatched `colorScheme` is therefore a flash of the wrong surface, not
|
|
173
|
+
a stuck one.
|
|
174
|
+
|
|
175
|
+
**The plugin hoists your `<meta charset>`.** Every injected tag defaults to `head-prepend`, which
|
|
176
|
+
landed ~20 tags ahead of the consumer's own encoding declaration — measured at byte 1653 on a
|
|
177
|
+
realistic shell, past the 1024-byte window the HTML spec gives it, with no consumer-side fix
|
|
178
|
+
available. The plugin now re-emits the declaration as its own first head-prepend tag (46 bytes after
|
|
179
|
+
the fix) and runs `order: 'pre'` so nothing else can land ahead of it. The legacy `http-equiv` form
|
|
180
|
+
is detected and left verbatim.
|
|
181
|
+
|
|
136
182
|
Icons are **bring-your-own** — basalt-ui takes no `sharp`/image-processing dependency. The plugin
|
|
137
183
|
expects these filenames under `public/` (or `icons.dir` if customized):
|
|
138
184
|
|
|
@@ -150,8 +196,9 @@ source image — it lives in the CONSUMER's own devDependencies, not basalt-ui's
|
|
|
150
196
|
warns (does not fail) when `public/` exists but is missing one of these files.
|
|
151
197
|
|
|
152
198
|
`basaltAppPlugin` also emits `site.webmanifest` (served in dev too) with explicit `id`/`scope`/
|
|
153
|
-
`start_url` — pass `manifest: false` to skip it, or `icons: false` to skip the
|
|
154
|
-
|
|
199
|
+
`start_url` — pass `manifest: false` to skip it, or `icons: false` to skip the icons. Since 1.21.1
|
|
200
|
+
`icons: false` omits BOTH the head `<link>` tags and the manifest's `icons` member; before that it
|
|
201
|
+
skipped only the head, so a manifest shipped naming two PNGs the app never builds.
|
|
155
202
|
|
|
156
203
|
## Service worker (opt-in)
|
|
157
204
|
|
|
@@ -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.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
`DualPanel` carries the repo's only one.
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
file went unpoliced. To waive ONE node, put `theme-allow hand-rolled-plot — <why>` on it (or on a
|
|
61
|
+
comment-only line above it). A genuinely non-single-plot shape (multi-pane, radial, matrix)
|
|
62
|
+
declares the whole file with `theme-allow-file hand-rolled-plot — two panes over one x scale`,
|
|
63
|
+
anywhere in it; `DualPanel` carries the repo's only one. **`theme-allow-file` is the 1.21.0
|
|
64
|
+
spelling** — at 1.20.0 a node-scoped annotation was silently promoted to a file declaration, so
|
|
65
|
+
per-node scoping was not expressible; move the one word. The file that DEFINES `CartesianChart` is
|
|
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
|
|
97
|
-
an AST pass
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
- **The guard trap, narrowed at 1.21.0:** `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
|
|
@@ -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.21.0)
|
|
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
|
-
|
|
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
|
|
207
|
-
|
|
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:
|
|
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`
|
|
28
|
-
|
|
29
|
-
only when complex cross-component state genuinely warrants it (see
|
|
27
|
+
versioned localStorage primitive (see below). `BasaltShell` persists its own collapse state
|
|
28
|
+
through that same primitive since 1.21.0; 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.21.0)
|
|
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.21.0: 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.21.0 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.
|
|
46
|
+
## `theme-allow` — the 1.21.0 contract
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
- **
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
- **An annotation must START its comment** — after `//`, `/*`, `<!--`, a block-comment gutter `*`,
|
|
66
|
+
or nothing but whitespace. Before 1.21.0 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.21.0**: 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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
-
|
|
71
|
-
|
|
72
|
-
|
|
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,28 @@ 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
|
|
95
|
-
|
|
96
|
-
|
|
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: basalt consumers only, and component-shaped declarations only.** Since 1.21.1 the rule
|
|
145
|
+
gates on `isBasaltScopedFile` like every other rule in the file, and needs a function, an arrow, a
|
|
146
|
+
`memo`/`forwardRef` wrapper or a class extending one. A plain data class sharing a name with a
|
|
147
|
+
shipped export is a collision, not a fork — it fired on a `SlugTracker` class in a React-free
|
|
148
|
+
package that cannot import basalt-ui.
|
|
149
|
+
- **Scope: everything importable.** Since 1.21.0 it reads all nine published barrels, not just
|
|
150
|
+
`dist/index.d.ts` — so the `basalt-ui/charts` layer, which is where forks actually live, is finally
|
|
151
|
+
covered. Names a barrel merely re-exports from a third party are skipped: `./charts` passes
|
|
152
|
+
`Bar` / `Line` / `Pie` straight through from `@visx/shape`, and a local `Bar` is not a fork of
|
|
153
|
+
anything basalt wrote.
|
|
154
|
+
|
|
155
|
+
So the renamed majority goes unreported. **Detection does not substitute for expressiveness**: a
|
|
156
|
+
composite that cannot express a common case gets routed around by compliant-looking code, and a
|
|
157
|
+
name-collision rule only catches the authors who did not bother to rename. The habit it stands in
|
|
158
|
+
for is the actual control: check whether basalt already ships the thing before building it.
|
|
97
159
|
|
|
98
160
|
## Filled surfaces — the fill band
|
|
99
161
|
|
|
@@ -120,7 +120,11 @@ 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`
|
|
123
|
+
`basalt/shadow-basalt-export` reads all nine published barrels since 1.21.0 (the charts layer
|
|
124
|
+
included), but it warns only on an **exact** name collision, in a basalt-scoped file, on a
|
|
125
|
+
component-shaped declaration — round 5 found the renamed forks (`Cell`, `Box`, `Stat`) sail straight
|
|
126
|
+
past it. It is a tripwire, not coverage; nothing catches the
|
|
127
|
+
rest. Ask before you build; do not wait to be told.
|
|
124
128
|
|
|
125
129
|
The consumer series file (`<first basalt.root>/lib/series.ts` by default — so `src/lib/series.ts` on
|
|
126
130
|
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
|
|
214
|
-
|
|
215
|
-
|
|
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.21.0 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`
|
|
6
|
-
|
|
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,
|
|
22
|
-
`
|
|
23
|
-
|
|
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
|
package/configs/lefthook.yml
CHANGED
|
@@ -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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
|
12
|
-
# in CI (the seeded check.yml does) rather than
|
|
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
|
|
17
|
-
#
|
|
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
|
|
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
|
|
46
|
-
#
|
|
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
|