basalt-ui 1.19.1 → 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.
- package/AGENTS.md +19 -1
- package/CHANGELOG.md +39 -0
- package/MIGRATING.md +326 -0
- package/README.md +87 -9
- package/agent/rules/basalt-app.md +84 -0
- package/agent/rules/basalt-charts.md +32 -17
- package/agent/rules/basalt-content.md +4 -0
- package/agent/rules/basalt-mantine.md +25 -10
- package/agent/rules/basalt-router.md +79 -15
- package/agent/rules/basalt-state.md +21 -3
- package/agent/rules/basalt-tokens.md +132 -6
- package/agent/skills/basalt-app/SKILL.md +45 -6
- package/agent/skills/basalt-charts/SKILL.md +5 -3
- package/agent/templates/CLAUDE-block.md.tpl +11 -5
- package/configs/check.yml +11 -4
- package/configs/lefthook.yml +59 -7
- package/configs/oxlint-plugin.js +633 -30
- package/configs/oxlint-plugin.test.ts +594 -15
- package/configs/oxlint.json +10 -1
- package/dist/agent/stick-to-bottom.js +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-message.js +2 -2
- package/dist/agent-chat/tool-chip.js +1 -1
- 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/Donut.js +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/charts/primitives/ChartLegend.js +2 -2
- package/dist/charts/primitives/ChartTooltip.js +1 -1
- package/dist/cli/index.d.ts +146 -20
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +901 -101
- package/dist/cli/toolchain-wiring.test.d.ts +2 -0
- package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
- package/dist/cli/waiver-audit.test.d.ts +2 -0
- package/dist/cli/waiver-audit.test.d.ts.map +1 -0
- package/dist/dashboard/delta-badge.js +2 -2
- package/dist/dashboard/stat-card.js +1 -1
- package/dist/data/virtual-list.js +5 -5
- package/dist/guard/index.d.ts +101 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +474 -62
- package/dist/guard/types.d.ts +25 -5
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/register.d.ts +25 -0
- package/dist/register.d.ts.map +1 -1
- 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/app-mobile-nav.module.css +11 -3
- 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/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/dist/tokens/index.d.ts +5 -3
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +7 -1
- package/dist/tokens/palette.d.ts +35 -27
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +24 -18
- 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 +21 -17
- package/dist/vite-color-mix.js +2 -2
- package/dist/vite.d.ts +24 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +54 -13
- package/llms.txt +3 -3
- package/package.json +4 -3
- package/src/agent/stick-to-bottom.tsx +1 -1
- package/src/agent-chat/thread-detail-panel.tsx +1 -1
- package/src/agent-chat/thread-message.tsx +3 -3
- package/src/agent-chat/tool-chip.tsx +1 -1
- package/src/charts/kinds/Bars.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +3 -2
- 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/charts/primitives/ChartLegend.tsx +2 -2
- package/src/charts/primitives/ChartTooltip.tsx +1 -1
- package/src/cli/index.ts +1614 -164
- package/src/cli/init-sync.test.ts +21 -3
- package/src/cli/template-ledger.test.ts +1 -1
- package/src/cli/tokens-css.test.ts +27 -14
- package/src/cli/toolchain-wiring.test.ts +856 -0
- package/src/cli/waiver-audit.test.ts +131 -0
- package/src/dashboard/delta-badge.tsx +3 -3
- package/src/dashboard/stat-card.tsx +1 -1
- package/src/data/virtual-list.tsx +8 -8
- package/src/guard/check-source.test.ts +1104 -12
- package/src/guard/index.ts +1106 -103
- package/src/guard/types.ts +29 -4
- package/src/register.ts +32 -0
- 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.module.css +11 -3
- 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/surfaces.ts +1 -1
- package/src/theme/density-relations.test.ts +8 -5
- package/src/theme/spacing.test.ts +2 -0
- package/src/theme-toggle/index.tsx +2 -2
- package/src/tokens/index.ts +28 -3
- package/src/tokens/palette.ts +24 -18
- package/src/tokens/text-scale.test.ts +51 -0
- package/src/vite-color-mix.ts +2 -2
- package/src/vite.test.ts +125 -6
- package/src/vite.ts +148 -20
|
@@ -69,6 +69,68 @@ The one real caveat is a plugin that **emits files from its own `closeBundle`**:
|
|
|
69
69
|
`vite-plugin-pwa`'s, those files are written too late to be precached (the `rollup-plugin-copy`
|
|
70
70
|
class of bug). `vite-plugin-pwa` exposes `integration.closeBundleOrder` to sequence against it.
|
|
71
71
|
|
|
72
|
+
## What `basalt-ui init` writes into `package.json`
|
|
73
|
+
|
|
74
|
+
`init` is not only a file-scaffolder. It also patches two keys, and on an existing app it is a
|
|
75
|
+
**lint-debt event, not a no-op** — the shipped preset switches on whole oxlint plugins the repo was
|
|
76
|
+
never linted against, so previously-clean code lands with real findings on the first run. `init`
|
|
77
|
+
prints the plugin list; run `oxlint .` and triage it before your next commit rather than
|
|
78
|
+
blanket-disabling a plugin.
|
|
79
|
+
|
|
80
|
+
- **`basalt.roots`** — inferred from the real layout (workspace packages depending on basalt or
|
|
81
|
+
Mantine, else every workspace `src/`, else `src`). Everything derives from it: `check-theme`'s
|
|
82
|
+
scan, the seeded CI `oxfmt` globs, the default scan exemption. Correct it if your sources live
|
|
83
|
+
elsewhere; without it a workspace repo scaffolds a guard that scans zero files.
|
|
84
|
+
- **`scripts["lint:basalt"]`** — `oxlint . && basalt-ui check-theme`. Previously the docs told you
|
|
85
|
+
to wire this and nothing wrote it.
|
|
86
|
+
- **`basalt.profile: 'tokens-only'`** — for a consumer on the `--vx-*` layer with no Mantine. It
|
|
87
|
+
must be DECLARED (or passed as `--tokens-only`); `check-theme` deliberately never infers it,
|
|
88
|
+
because inferring from a missing `@mantine/core` would silence 17 kinds on any repo that keeps
|
|
89
|
+
Mantine in a different workspace package. `doctor` does infer it, because its profile only changes
|
|
90
|
+
which ADVICE it prints and never what it enforces — and it names the key to write down.
|
|
91
|
+
|
|
92
|
+
`init` keeps an existing `.oxlintrc.json` rather than clobbering it, and now names what keeping it
|
|
93
|
+
costs (the whole basalt lint half stays off). `--merge-lint` splices the preset's `extends` in; it
|
|
94
|
+
refuses on a commented config rather than deleting the comments.
|
|
95
|
+
|
|
96
|
+
Run `basalt-ui doctor` afterwards to confirm the wiring took. A check that cannot RUN is reported as
|
|
97
|
+
`SKIPPED` and exits non-zero on its own — "All checks passed" is only printable when every check ran.
|
|
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
|
+
|
|
118
|
+
## `index.html` and `public/` are scanned
|
|
119
|
+
|
|
120
|
+
`check-theme` resolves `.html` / `.webmanifest` / `.json` as markup (colour kinds only), and each
|
|
121
|
+
`basalt.roots` entry's PARENT contributes its `index.html` and its `public/` tree — exactly the Vite
|
|
122
|
+
layout above. So a raw hex in a `theme-color` meta or a webmanifest's `background_color` is a
|
|
123
|
+
finding now; those are the two colours nothing re-derives on a scheme change. Nothing else in the
|
|
124
|
+
parent is walked, and `.json` is never blanket-scanned — name one in `basalt.include`.
|
|
125
|
+
|
|
126
|
+
## `__APP_VERSION__`
|
|
127
|
+
|
|
128
|
+
`basaltViteConfig` injects the `__APP_VERSION__` define, and since 1.20.0 ships the ambient
|
|
129
|
+
declaration with it (`src/register.ts` → `dist/register.d.ts`, re-exported by the root barrel). An
|
|
130
|
+
app that imports anything from `basalt-ui` has it in scope — delete your hand-written ambient block
|
|
131
|
+
and its `oxlint-disable`. A subpath-only consumer does not get it, which is the same set as the
|
|
132
|
+
consumers not on this Vite preset anyway.
|
|
133
|
+
|
|
72
134
|
## CSS layer order (Mantine)
|
|
73
135
|
|
|
74
136
|
`basalt-ui/styles.css` must load AFTER every `@mantine/*/styles.layer.css` bundle — the layered
|
|
@@ -90,6 +152,28 @@ they track the palette automatically. It also injects the `apple-mobile-web-app-
|
|
|
90
152
|
`viewport-fit=cover` (skipped when the consumer's own `index.html` already declares one), and
|
|
91
153
|
site-wide OG/Twitter defaults from `options.seo`.
|
|
92
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
|
+
|
|
93
177
|
Icons are **bring-your-own** — basalt-ui takes no `sharp`/image-processing dependency. The plugin
|
|
94
178
|
expects these filenames under `public/` (or `icons.dir` if customized):
|
|
95
179
|
|
|
@@ -55,19 +55,33 @@ radial/matrix shape, is not optional — two `basalt` oxlint plugin rules
|
|
|
55
55
|
|
|
56
56
|
- **`basalt/hand-rolled-plot`** — rendering a chart-assembly primitive (`AxisLeftNumeric`,
|
|
57
57
|
`AxisRightNumeric`, `AxisBottomDate`, `HoverOverlay`, `Crosshair`) in a file that does not
|
|
58
|
-
compose `CartesianChart
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
compose `CartesianChart`. **Since 1.20.0 every assembly node is reported and waived on its own**
|
|
59
|
+
— one comment no longer grants the whole file permanent immunity, which is how a 604-line chart
|
|
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"
|
|
62
67
|
cannot fire inside X.
|
|
63
|
-
- **`basalt/chart-legend-literal`** —
|
|
64
|
-
`items
|
|
65
|
-
|
|
68
|
+
- **`basalt/chart-legend-literal`** — a hand-written array literal passed to `ChartLegend`'s
|
|
69
|
+
`items`; **since 1.20.0 also a `.map()` over a non-`series` array**, because deriving from AN
|
|
70
|
+
array is not deriving from THE series. The legend must come from the same `series` the chart
|
|
71
|
+
draws (`deriveLegend`, or just let `ChartFrame`/`CartesianChart` do it), so it cannot go stale
|
|
66
72
|
naming a series the plot no longer draws.
|
|
67
73
|
|
|
68
|
-
Both ship
|
|
69
|
-
|
|
70
|
-
|
|
74
|
+
Both ship `warn` in the consumer preset (`configs/oxlint.json`) and `error` repo-local, per the
|
|
75
|
+
"Shipping a stricter guard — the grace minor" doctrine in `packages/basalt-ui/CLAUDE.md`. 1.20.0
|
|
76
|
+
WIDENS both, so both restart their grace rather than promoting — the live ledger with the promotion
|
|
77
|
+
note per rule is `PLUGIN_RULE_GRACE`, exported beside the plugin in `configs/oxlint-plugin.js`, and
|
|
78
|
+
a test asserts it against the shipped preset in both directions.
|
|
79
|
+
|
|
80
|
+
**A documented limit:** `hand-rolled-plot` keys on those visx primitives, so a chart drawn with DOM
|
|
81
|
+
nodes (divs sized in percent, an SVG assembled by hand) is structurally invisible to it. That is a
|
|
82
|
+
deliberate trade — every alternative detector tried either flagged basalt's own `Donut`/`Heatmap` or
|
|
83
|
+
an icon in a card header, and a noisy shipped rule gets switched off. Compose `CartesianChart`
|
|
84
|
+
because it is the contract, not because lint will catch you.
|
|
71
85
|
|
|
72
86
|
## Maps are not charts
|
|
73
87
|
|
|
@@ -81,12 +95,13 @@ ships no map kind.
|
|
|
81
95
|
- **Want `@visx/geo` anyway?** Install it yourself — basalt doesn't pin it — and keep it under a
|
|
82
96
|
`charts/` segment like every other visx import, same as any bespoke chart.
|
|
83
97
|
- **Style map chrome with `--vx-*` tokens** (`VX.*`/`alpha()`) so it matches the rest of the app.
|
|
84
|
-
- **The guard trap:** `check-theme`'s `inline-spacing` kind is a per-line regex
|
|
85
|
-
an AST pass
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
90
105
|
|
|
91
106
|
## Every chart has
|
|
92
107
|
|
|
@@ -155,7 +170,7 @@ ships no map kind.
|
|
|
155
170
|
in the geometry; and because the domain value must be unique, two events at the same instant
|
|
156
171
|
collapse onto one position and one stops being drawn. `getX` returning a date string reads like a
|
|
157
172
|
time axis and is not one. Tracked as issue #52; a chart whose x is a measured quantity currently
|
|
158
|
-
needs a `basalt/hand-rolled-plot`
|
|
173
|
+
needs a `basalt/hand-rolled-plot` file declaration.
|
|
159
174
|
|
|
160
175
|
6. **The cursor is shared by default.** No provider needed — `useChartCursor` reads a module-level
|
|
161
176
|
external store (`useSyncExternalStore`), so every `CartesianChart`/`ChartFrame`-composed chart on
|
|
@@ -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
|
|
@@ -149,14 +149,26 @@ section divider, not card depth) are both fine.
|
|
|
149
149
|
consumer code must compose Mantine layout/surface primitives — `Box`, `Flex`, `Grid`,
|
|
150
150
|
`SimpleGrid`, `Stack`, `Group`, `Paper`, `Card` — instead of raw `<div>`/`<span>` with inline
|
|
151
151
|
`style`. Raw HTML with inline layout/surface styling defeats the token system.
|
|
152
|
-
- **
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
152
|
+
- **The second fix is a CSS Module, and it is the right one in list code.** What
|
|
153
|
+
`raw-html-layout` actually objects to is the inline `style` literal, not the `<div>`. Moving the
|
|
154
|
+
rule into a `.module.css` class that reads `var(--vx-*)` satisfies the guard, keeps the values
|
|
155
|
+
in the token system, and costs zero runtime — where a Mantine primitive costs one component
|
|
156
|
+
instance per row. In a row renderer that paints hundreds of items, or in a downstream library
|
|
157
|
+
that must not force `@mantine/core` on its consumers, take the CSS Module. `check-theme` scans
|
|
158
|
+
`.css` and `.module.css`, so the tokens stay policed there. The guard's fix text names only the
|
|
159
|
+
Mantine route; both are sanctioned.
|
|
160
|
+
- **Mechanical enforcement.** `basalt-ui check-theme` adds eight surface guard kinds (each a config
|
|
161
|
+
knob, default ON; scoped `theme-allow <rule-id> — <reason>` escape): `off-system-surface-var` (raw
|
|
162
|
+
ramp-step vars), `card-with-border` (`withBorder` on a `Card`/`Paper`), `mantine-shade-index` (a
|
|
163
|
+
shade-pinned color — see below), `raw-html-layout` (raw `<div>`/`<span>` with inline layout/surface
|
|
164
|
+
styling), `inline-spacing` (inline spacing literals), `inline-display` (inline `display` literals),
|
|
165
|
+
and since 1.20.0 `css-raw-surface` (the same surface kinds in their kebab CSS dialect, so
|
|
166
|
+
`border-radius: 4px` in a CSS module is no longer invisible beside a flagged `borderRadius: 2`) and
|
|
167
|
+
`hidden-inline-style` (`raw-html-layout`'s formatting-independent half — a multi-line opening tag,
|
|
168
|
+
or a style object hoisted to a const). The Mantine-free `src/charts/**` is the only place raw
|
|
169
|
+
`<div>` is allowed — and it must still use `VX.*` tokens. `oxlint` adds `basalt/card-inset`, which
|
|
170
|
+
catches the other half of the same card drift: an explicit `p`/`padding`/`radius` on a
|
|
171
|
+
`Card`/`Paper` instead of the xs/sm inset and the theme radius.
|
|
160
172
|
|
|
161
173
|
**Never pin a shade index on a color.** `c="yellow.7"`, `bg="blue.4"`, `var(--mantine-color-red-6)` —
|
|
162
174
|
each names one fixed swatch that is identical in light and dark, so a shade legible on the dark page
|
|
@@ -278,8 +290,11 @@ shadow-surfaces.test.ts` mechanically enumerates every site that applies either
|
|
|
278
290
|
|
|
279
291
|
## Composing the shell from sub-components
|
|
280
292
|
|
|
281
|
-
`BasaltShell` is the canonical single-mount
|
|
282
|
-
|
|
293
|
+
`BasaltShell` is the canonical single-mount, and since 1.20.0 `basalt/hand-rolled-shell` (`warn`)
|
|
294
|
+
says so mechanically: `AppShell.*` parts or a `Burger` in a file that does not render `BasaltShell`
|
|
295
|
+
is the layer the 1.19 nav contract replaces. It is file-scoped and reported once — which shell a
|
|
296
|
+
file uses is one decision. When a fully custom layout is genuinely needed, the shell sub-components
|
|
297
|
+
are available individually from `basalt-ui`:
|
|
283
298
|
|
|
284
299
|
- `AppSidebar` — desktop sidebar (sections, collapse, brand, settings/account extras)
|
|
285
300
|
- `MobileNav` — the bottom tab bar (takes a `MobileNavModel` from `projectMobileNav`)
|
|
@@ -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
|
|
@@ -104,10 +151,16 @@ Install `@tanstack/react-router` as a peer, then import from `basalt-ui/router-t
|
|
|
104
151
|
|
|
105
152
|
**Prerequisites:**
|
|
106
153
|
|
|
107
|
-
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
154
|
+
- **The only hard prerequisite is the `Register` module augmentation** (see "The prerequisite that
|
|
155
|
+
makes all of this real" below). `defineNav` / `navGroup` / `navTarget` / `flattenNav` / `useNav` /
|
|
156
|
+
`createSearchParamStore` all work against a hand-written `createRouter({ routeTree })` with
|
|
157
|
+
code-defined routes, no Vite plugin and no generated tree — verified end-to-end in a consumer,
|
|
158
|
+
including that a bad `to` still prints the real route-path union.
|
|
159
|
+
- **`@tanstack/router-plugin/vite` + `tsr generate` are needed only for `staticData`** — that is,
|
|
160
|
+
for `useRouterBreadcrumbs` and any nav metadata read off the route tree. The
|
|
161
|
+
`StaticDataRouteOption` augmentation takes effect once `src/routeTree.gen.ts` exists. In CI and
|
|
162
|
+
after adding routes, run `tsr generate` (or `bun run typecheck`, which triggers it) before relying
|
|
163
|
+
on breadcrumb data.
|
|
111
164
|
- The loader's `context.queryClient` requires `createRootRouteWithContext<{ queryClient: QueryClient }>()`.
|
|
112
165
|
Run `basalt-ui init` to scaffold a `query-client.ts` + `__root.tsx` seed that wires the TanStack
|
|
113
166
|
Router + Query context correctly — the generated `__root.tsx` uses
|
|
@@ -157,6 +210,7 @@ closing a cycle.
|
|
|
157
210
|
```tsx
|
|
158
211
|
import { linkOptions } from '@tanstack/react-router'
|
|
159
212
|
import { defineNav, navGroup } from 'basalt-ui/router-tanstack'
|
|
213
|
+
import { garminWindow } from './search-params' // a createSearchParamStore
|
|
160
214
|
|
|
161
215
|
const ICON = 18
|
|
162
216
|
|
|
@@ -169,7 +223,9 @@ export const NAV = defineNav({
|
|
|
169
223
|
short: 'Garmin', // bar/menu label — keep it ≤ 10 chars
|
|
170
224
|
mobile: 'tab', // give this destination its own slot on the mobile bar
|
|
171
225
|
icon: <IconHeartbeat size={ICON} />,
|
|
172
|
-
|
|
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 }),
|
|
173
229
|
},
|
|
174
230
|
{
|
|
175
231
|
id: 'reading',
|
|
@@ -197,9 +253,11 @@ Rules that are not stylistic:
|
|
|
197
253
|
`label`, `short`, `icon`, `mobile`, `disabled` and `exact` ride outside it. A flat shape
|
|
198
254
|
(`{ id, label, to }`) validates `to` by ASSIGNABILITY, which does no excess-property checking, so
|
|
199
255
|
a typo'd metadata key would compile silently. A top-level `to:` is a compile error by design.
|
|
200
|
-
- **`search` may be a thunk
|
|
201
|
-
|
|
202
|
-
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.
|
|
203
261
|
- **Nesting is `children`**, and nested destinations are indexed by id just like top-level ones.
|
|
204
262
|
- **`NavItemMeta`**'s `mobile` field places the destination on the bar: `'tab'` (own slot),
|
|
205
263
|
`'more'` (the default — reachable through the More overlay) or `'hidden'`. A group claims a slot
|
|
@@ -231,7 +289,7 @@ unreadable in a 56px tab.
|
|
|
231
289
|
`router.navigate()` or `redirect()` instead of restating the route and its default search:
|
|
232
290
|
|
|
233
291
|
```ts
|
|
234
|
-
throw redirect(navTarget(NAV, 'garmin')) // exactly { to: '/garmin-health', search:
|
|
292
|
+
throw redirect(navTarget(NAV, 'garmin')) // exactly { to: '/garmin-health', search: garminWindow.linkSearch }
|
|
235
293
|
```
|
|
236
294
|
|
|
237
295
|
**`flattenNav`** walks the definition depth-first (parent, then children), tagging each destination
|
|
@@ -255,12 +313,18 @@ With that missing, every `to` widens to `string`, a nonexistent route path compi
|
|
|
255
313
|
required `search` compiles, and the API looks like it is working while catching nothing. This is
|
|
256
314
|
the single highest-value thing to verify when adopting the config: change one `to` to a garbage
|
|
257
315
|
path and confirm you get a compile error listing the real route paths. If you don't, the
|
|
258
|
-
augmentation is missing or `tsr generate` has not run.
|
|
259
|
-
|
|
260
|
-
Second thing to know before you migrate:
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
316
|
+
augmentation is missing (or, on a file-route setup, `tsr generate` has not run).
|
|
317
|
+
|
|
318
|
+
Second thing to know before you migrate: whether a missing `search` is caught depends on the
|
|
319
|
+
schema's OUTPUT type, not on the `validateSearch` signature. If every key of `T` is required —
|
|
320
|
+
which is what a Zod object with defaults produces, since defaults apply on parse and the output has
|
|
321
|
+
no optionals — TanStack raises `MakeRequiredSearchParams` and the compiler catches it. Two routes in
|
|
322
|
+
one consumer had been shipping nav links with no search params behind a `to={target.to as never}`
|
|
323
|
+
cast; removing the callback surfaced both immediately.
|
|
324
|
+
|
|
325
|
+
**The caveat holds only when `T` has no required keys** (every key optional, or the validator
|
|
326
|
+
returns a partial). There, a forgotten `search` compiles — diff the old target table against the new
|
|
327
|
+
definition by hand, key by key. Everywhere else, trust the error.
|
|
264
328
|
|
|
265
329
|
Error messages here are the router's own and they are large: one wrong `to` prints the whole
|
|
266
330
|
route-path union plus a `RouterCore<…>` blob, because TanStack's `Constrain` falls back to the
|
|
@@ -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.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
|
|
@@ -24,12 +24,133 @@ status pops — never floods. (Blueprint/Basalt zinc-charcoal are the historical
|
|
|
24
24
|
ancestors; `docs/DESIGN-SPEC.md` in the basalt-ui repo supersedes both — see its "Doctrine
|
|
25
25
|
inversions" section.) This
|
|
26
26
|
rule is the operational checklist; it is enforced mechanically by **`basalt-ui check-theme`**
|
|
27
|
-
(wire it into `lint`: `oxlint . && basalt-ui check-theme`
|
|
28
|
-
`theme-allow
|
|
27
|
+
(wire it into `lint`: `oxlint . && basalt-ui check-theme`; `init` seeds that as `lint:basalt`). A
|
|
28
|
+
violation fails the build. Escape hatch: a scoped `theme-allow <rule-id> — <reason>` comment
|
|
29
|
+
(diff-visible, deliberate) — see the contract below.
|
|
29
30
|
|
|
30
31
|
`check-theme` reads its config from your `package.json` `"basalt"` key
|
|
31
|
-
(`{ roots?, exempt?, spacingSteps?, forbiddenAccents? }`); set `roots`
|
|
32
|
-
to the files that legitimately define palette values.
|
|
32
|
+
(`{ roots?, exempt?, include?, profile?, severity?, spacingSteps?, forbiddenAccents? }`); set `roots`
|
|
33
|
+
to your source dirs and `exempt` to the files that legitimately define palette values.
|
|
34
|
+
|
|
35
|
+
**`roots` is not optional in practice.** Omitted it falls back to `src` relative to the cwd — fine
|
|
36
|
+
for a single-app repo, zero files for anything under `apps/*/src`, and `check-theme` then exits 1
|
|
37
|
+
with `0 files scanned`. Since 1.20.0 `init` writes a real `roots` from the detected layout and
|
|
38
|
+
`doctor`'s `guard-scan` check fails when the configured roots resolve to nothing; before that a repo
|
|
39
|
+
could look fully wired while the gate scanned nothing.
|
|
40
|
+
|
|
41
|
+
The scan reaches slightly beyond `roots`: each root's PARENT contributes its `index.html` and its
|
|
42
|
+
`public/` tree (the Vite layout basalt's own preset assumes), because that is where a `theme-color`
|
|
43
|
+
meta and a webmanifest's `background_color` actually live. `.json` is never blanket-scanned — name a
|
|
44
|
+
design-surface JSON in `basalt.include`, the only route to one.
|
|
45
|
+
|
|
46
|
+
## `theme-allow` — the 1.20.1 contract
|
|
47
|
+
|
|
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.
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
// theme-allow raw-surface — third-party widget needs a literal corner
|
|
62
|
+
<Widget style={{ borderRadius: 3 }} />
|
|
63
|
+
```
|
|
64
|
+
|
|
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.
|
|
79
|
+
- **Spell the id right — a typo waives nothing.** A word in the id slot that names no rule is
|
|
80
|
+
recorded as unknown and suppresses nothing; the annotation covers exactly the ids it got right.
|
|
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.
|
|
88
|
+
- **Three placements work and both engines agree on all three**: the reported line, a comment-ONLY
|
|
89
|
+
line directly above it (the only form JSX can express — the reported line is usually a multi-line
|
|
90
|
+
opening tag or a `{expr}` child), and in CSS a trailing annotation reaching back over the
|
|
91
|
+
declaration it terminates. The third is what survives the shipped `oxfmt` reflowing a long
|
|
92
|
+
`background-color` so the hex lands ABOVE the comment.
|
|
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.
|
|
116
|
+
|
|
117
|
+
### New in 1.20.0 — five guard kinds, all `warn` for one minor
|
|
118
|
+
|
|
119
|
+
| Kind | Fires on |
|
|
120
|
+
| ------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
121
|
+
| `theme-allow-unscoped` | a `theme-allow` with no rule id, no reason, or an id that names no rule |
|
|
122
|
+
| `surface-shadow-override` | a `boxShadow` built FROM tokens that REPLACES `--vx-shadow-card` instead of composing with it |
|
|
123
|
+
| `css-raw-surface` | the kebab dialect of the surface kinds in CSS (`border-radius: 6px`); sub-scale corners and circle/pill values pass |
|
|
124
|
+
| `inline-font-size` | `style={{ fontSize: 11 }}` — the `check-theme` half of `basalt/no-raw-font-size`, for a CI that runs only the guard |
|
|
125
|
+
| `hidden-inline-style` | a multi-line opening tag, or a style object hoisted to a const — `raw-html-layout`'s formatting-independent half |
|
|
126
|
+
|
|
127
|
+
Two new oxlint rules ship at `warn` alongside them: **`basalt/shadow-basalt-export`** (below) and
|
|
128
|
+
**`basalt/hand-rolled-shell`** (`AppShell` parts or `Burger` in a file that does not render
|
|
129
|
+
`BasaltShell`). **`basalt/raw-size-literal` is now `error`** — CSS-length strings on
|
|
130
|
+
`size`/`fz`/`fontSize`. Every kind keeps its own boolean, `basalt.severity` and `exemptRules`.
|
|
131
|
+
|
|
132
|
+
## The guard sees palette, not vocabulary
|
|
133
|
+
|
|
134
|
+
Round 4 swept seven consumer repos: every gate green, and ~15 independently re-rolled copies of
|
|
135
|
+
components basalt already ships (`StatCard` in 4 of 4 apps, `EmptyState`, a 266-line hand-rolled
|
|
136
|
+
`AppShell`). A fork written by a token-fluent author uses exactly the right tokens, so no palette
|
|
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.
|
|
33
154
|
|
|
34
155
|
## Filled surfaces — the fill band
|
|
35
156
|
|
|
@@ -158,5 +279,10 @@ export const paletteGroups = { [`${GROUP}-`]: SERIES } // -> { 'activity-': SERI
|
|
|
158
279
|
## When the guard fires
|
|
159
280
|
|
|
160
281
|
Fix the source, don't silence it — reach for the right token first. Only add `theme-allow` for a
|
|
161
|
-
genuine, documented exception (
|
|
162
|
-
|
|
282
|
+
genuine, documented exception (a third-party widget needing a literal), scoped to the rule id, with
|
|
283
|
+
a reason: a bare `// theme-allow` passes the check, tells the next reader nothing, and now reports
|
|
284
|
+
`theme-allow-unscoped` besides. The palette-definition files are listed in your `exempt` set so they
|
|
285
|
+
don't self-trip. A stylesheet emitted by `basalt-ui tokens:css` needs no entry — in a `.css` file
|
|
286
|
+
carrying that exact two-line `@generated basalt-ui` header, the guard skips the LINES that are
|
|
287
|
+
basalt custom properties, selectors, `}` or self-closing comments. Pasting the marker anywhere else
|
|
288
|
+
suppresses nothing, and an ordinary declaration added to such a file is still reported.
|