basalt-ui 1.21.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 CHANGED
@@ -87,7 +87,9 @@ over the declaration it terminates. Whole-file scope is `theme-allow-file <id>
87
87
  `theme-allow-file` waives nothing. A bare `theme-allow` still waives everything on its placement but
88
88
  reports `theme-allow-unscoped`. A word in the id slot that names no rule waives nothing.
89
89
  `.json`/`.webmanifest` use a `"basalt:theme-allow[-file]"` member. Audit them all with
90
- `basalt-ui check-theme --audit-allows`, which exits 1 on a waiver that suppresses nothing.
90
+ `basalt-ui check-theme --audit-allows`, which exits 1 on a waiver that suppresses nothing. It judges
91
+ plugin-rule waivers too (since 1.21.1), by re-running oxlint — so it needs oxlint reachable;
92
+ otherwise the verdict is "cannot judge", never "dead".
91
93
 
92
94
  > **Framework-internal only** — `bunx basalt-ui check-coverage` is a self-consistency gate for the
93
95
  > basalt-ui repo itself (asserts SURFACES ↔ rule files ↔ skill files ↔ package.json exports). It
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [1.22.0](https://github.com/jkrumm/basalt-ui/compare/v1.21.0...v1.22.0) (2026-08-22)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * let `icons: false` reach the manifest it was only skipping in the head ([58abe27](https://github.com/jkrumm/basalt-ui/commit/58abe27be10e5baf7c4659aa79904a7646ff092d))
7
+ * scope the shadow-export tripwire to basalt consumers, and to components ([60716d4](https://github.com/jkrumm/basalt-ui/commit/60716d4ba00908c1a157f5568e192c6c50e438ec))
8
+ * stop sync scaffolding a second consumer, and give the audit the oxlint half ([45cf3d3](https://github.com/jkrumm/basalt-ui/commit/45cf3d340f081e61f3f10f852d12f79928993d2a))
9
+
10
+
11
+ ### Features
12
+
13
+ * give the waiver audit an annotation reader, and fix two shapes ([0dde850](https://github.com/jkrumm/basalt-ui/commit/0dde85052269780a61ead0688005db16e24f009a))
14
+
1
15
  # [1.21.0](https://github.com/jkrumm/basalt-ui/compare/v1.20.0...v1.21.0) (2026-08-22)
2
16
 
3
17
 
package/MIGRATING.md CHANGED
@@ -8,9 +8,9 @@ renamed exports per minor, with the replacement.
8
8
  Reconstructed from `git diff` over the published export surface across `v1.0.0..v1.19.1`, then
9
9
  cross-checked against the repo's `scripts/export-surface.json` snapshot. **Every replacement below
10
10
  was re-audited against the built declaration files at 1.20.0 (2026-08-22)** after round 5 caught one
11
- row that was wrong. That pass corrected 4 table rows and 3 prose claims. The 1.20.1 section was
12
- written against source, not against its commit messages. Check the types, not this table, if the two
13
- disagree.
11
+ row that was wrong. That pass corrected 4 table rows and 3 prose claims. The 1.21.0 and 1.21.1
12
+ sections were written against source, not against their commit messages. Check the types, not this
13
+ table, if the two disagree.
14
14
 
15
15
  **No majors, by policy.** A rename or a removal ships as a plain `feat:` on the 1.x line, so a minor
16
16
  bump can require code changes. Skipping several at once is the expensive case — read every section
@@ -20,12 +20,95 @@ between your version and the target.
20
20
  > `## [1.` finds only the patches.
21
21
 
22
22
  **Minors with no public API delta:** 1.1.0, 1.3.0, 1.4.0, 1.5.0, 1.6.0, 1.7.0, 1.8.0, 1.9.0,
23
- 1.10.0, 1.13.0, 1.14.0, 1.16.0, 1.18.0 — and every patch. Additive-only subpaths: `./tokens.css`
23
+ 1.10.0, 1.13.0, 1.14.0, 1.16.0, 1.18.0 — and every patch except 1.21.1. Additive-only subpaths: `./tokens.css`
24
24
  at 1.3.0, `./agent-chat` at 1.10.0.
25
25
 
26
26
  ---
27
27
 
28
- ## 1.20.1 — the `theme-allow` grammar
28
+ ## 1.21.1 — the toolchain stops overclaiming
29
+
30
+ **No export removed or renamed; four added.** Nothing you wrote changed. Every entry below is a
31
+ check that reported an answer it had not earned, so a green CI on 1.21.0 was, for several of them,
32
+ green over an empty set.
33
+
34
+ **`sync` refuses where it used to scaffold.** Run from a sub-package that depends on basalt-ui but
35
+ holds no `.basalt/manifest.json` — `apps/dashboard`, `apps/web` — it printed `0 updated, 20
36
+ recreated`, wrote a second `basalt` key, and stood up a complete competing install beside the real
37
+ one at the repo root. argo and rb both reverted it by hand. It now resolves its project exactly as
38
+ `check-theme` and `doctor` do, and **exits 1** when the resolved project has no manifest, naming the
39
+ install it found above instead. The refusal runs before the `basalt.roots` backfill, which was half
40
+ the damage. Two ways to unblock it:
41
+
42
+ ```bash
43
+ cd <the package holding .basalt/manifest.json> && bunx basalt-ui sync
44
+ BASALT_CWD=<that package> bunx basalt-ui sync # or from anywhere
45
+ ```
46
+
47
+ The summary line gained a `created` counter. **`recreated` now means what it always claimed** — the
48
+ ledger placed that file once and it went missing. Twenty first-time writes were never that.
49
+
50
+ **`--audit-allows` judges plugin-rule annotations.** They used to print "not a check-theme kind" and
51
+ drop out of the exit-1 gate, so the gate covered an empty set wherever the waivers were plugin
52
+ rules: argo's whole tally was `0 live, 0 dead, 8 outside reach`, and 11 of linewatch's 14 went the
53
+ same way. Each is now probed by re-running oxlint over that one file with the annotation
54
+ neutralized. Judged now: argo 8 of 8, linewatch 14 of 14, basalt's own tree 23 of 23.
55
+
56
+ **It requires oxlint to be reachable.** The probe writes one neutralized sibling file and re-runs
57
+ oxlint over it (oxlint has no stdin mode), removing it in a `finally`. Where oxlint cannot run, the
58
+ annotation is reported as **"cannot judge"**, never as dead. The report now also prints the scope it
59
+ audited — `0 dead` over `basalt.roots` was reading as `0 dead anywhere`.
60
+
61
+ **`doctor`'s `lefthook-preset` check asks a different question.** It tested whether the config text
62
+ contained the `extends` string. It now asks whether the gate EXISTS, via `lefthook dump`, which
63
+ resolves `extends`, `include` and per-command `root:`. linewatch wires all three jobs with
64
+ `root: 'web/'` precisely because `extends` merges commands _without_ their working directory — it
65
+ was correctly configured, got warned, and the old advice would have broken it. Three outcomes: a
66
+ broken `extends` target is still a hard fail, a provably absent gate is a warn, and where
67
+ `lefthook dump` could not run you get an advisory warning naming what it could not see.
68
+
69
+ **`basalt/shadow-basalt-export` narrowed twice.** It now gates on `isBasaltScopedFile` like every
70
+ other rule in the file, and needs a component-SHAPED declaration — a function, an arrow, a
71
+ `memo`/`forwardRef` wrapper, or a class extending one. It had been firing on a `SlugTracker` class
72
+ in a React-free package carrying no basalt-ui dependency, telling it to import from basalt-ui. The
73
+ stated limit is unchanged: exact-name-only, a tripwire, not coverage.
74
+
75
+ **`basaltAppPlugin({ icons: false })` now omits the manifest's `icons` member too.** It skipped the
76
+ head `<link>` icons and emitted the two PNG manifest entries anyway, so `{ manifest: true, icons:
77
+ false }` shipped an installable app pointing at two 404s. **If you went hybrid over this** — the
78
+ plugin for the head, a hand-written manifest for the rest — **you can drop the hand-written half.**
79
+ The option's JSDoc said "skips the head `<link>` icons"; it now says what the option does.
80
+
81
+ **Two `theme-allow` comment shapes were silently broken and now work.** Plainly: this is the fourth
82
+ hole found in this one contract in three rounds. The full thirteen-shape matrix is now pinned in
83
+ both halves — `src/guard/check-source.test.ts` and `configs/oxlint-plugin.test.ts` — so the two
84
+ parsers can no longer disagree about what an annotation IS.
85
+
86
+ | Shape | What it did | Now |
87
+ | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
88
+ | `/** theme-allow <id> — <why> */` on one line | waived under oxlint, reported under `check-theme` | both halves honour it |
89
+ | `{/*` + token on the next line + `*/}` | comment-stripping left a bare `}`, so the line read as CODE and the annotation was classified trailing — scoped to a brace | scoped to the node below it, as written |
90
+
91
+ **linewatch writes that wrapped shape for every hand-composed chart axis**, so any _guard_ kind
92
+ annotated that way was silently unwaivable. It only appeared to work because the rules it names
93
+ (`hand-rolled-plot`) live in the oxlint plugin, whose placement test is comment-node-based and never
94
+ saw the brace.
95
+
96
+ **New, additive — `./guard` gains four runtime exports**, the reader half of the audit:
97
+
98
+ | Export | Note |
99
+ | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
100
+ | `findAllowAnnotations(text, relPath, cfg)` | every annotation as written, ids split into `guardKinds` / `pluginRules` / `unknownRules`. Shares `collectAllowAnnotations` with `checkSource`, so it cannot list a line the scan does not honour |
101
+ | `neutralizeAllowAnnotation(text, line, cfg)` | one annotation rewritten to the token below, every other left intact — the probe half of an audit |
102
+ | `NEUTRALIZED_ALLOW_TOKEN` | so a guard probe and an oxlint probe neutralize identically |
103
+ | `PLUGIN_RULE_IDS` | the ids only oxlint can judge |
104
+
105
+ The `AllowAnnotationSite` type ships alongside them.
106
+
107
+ **Known gaps, reported and not fixed:** `--audit-allows` says nothing about `basalt.exempt` — a
108
+ whole file removed from the scan, the broadest exception the config surface has — and its
109
+ `scoped to …` line does not distinguish `theme-allow` from `theme-allow-file`.
110
+
111
+ ## 1.21.0 — the `theme-allow` grammar
29
112
 
30
113
  **No export removed or renamed.** One break, and it is in the escape hatch itself: **file scope must
31
114
  now be spelled `theme-allow-file`.** At 1.20.0 an annotation that named a rule and gave a reason —
@@ -73,7 +156,7 @@ manifest, `basaltAppPlugin` is the first remedy, since a hand-copied hex drifts
73
156
  | Change | What to do |
74
157
  | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
75
158
  | `createSearchParamStore` / `createMultiSearchParamStore` gain `linkSearch` | replace every nav link's `search: { <param>: '<literal>' }` with `search: <store>.linkSearch`, passed BY REFERENCE. A module-scope literal pins the fallback on every click — argo's reader had ZERO call sites, so "remember my window" had never worked |
76
- | `BasaltShell` collapse moves to `createPersistedState` | the key is now `basalt:<storageKey>` holding `{ v, value }`; read it with `readPersistedValue(storageKey, 1)`. A one-time migration adopts the raw pre-1.20.1 value, so the sidebar does not re-expand on upgrade |
159
+ | `BasaltShell` collapse moves to `createPersistedState` | the key is now `basalt:<storageKey>` holding `{ v, value }`; read it with `readPersistedValue(storageKey, 1)`. A one-time migration adopts the raw pre-1.21.0 value, so the sidebar does not re-expand on upgrade |
77
160
 
78
161
  **New, additive:**
79
162
 
@@ -157,7 +240,7 @@ the rule AND giving a reason is what `hasFileDeclaration` matches, at any line
157
240
  intended for one node silences the file. Dropping the reason keeps it node-scoped in the oxlint
158
241
  plugin, but then `check-theme` reports `theme-allow-unscoped ("no reason")`. The two halves of the
159
242
  contract intersect at exactly one legal shape and that shape is whole-file. Write the declaration in
160
- the component's docblock, where it reads as the file-level decision it is. **Fixed in 1.20.1** —
243
+ the component's docblock, where it reads as the file-level decision it is. **Fixed in 1.21.0** —
161
244
  see that section; the declaration moves to `theme-allow-file`.
162
245
 
163
246
  **`doctor` will go red.** `SKIPPED` is a third outcome beside pass/warn/fail and exits non-zero on
@@ -293,9 +376,9 @@ deleting an entry forces the level flip in the same commit.
293
376
  | `basalt/agent-no-raw-usechat` | 1.10.0 as `warn` | **1.13.0** |
294
377
  | `basalt/agent-resume-guard` | 1.10.0 as `warn` | **1.13.0** |
295
378
  | `basalt/raw-size-literal` | 1.7.0 as `warn` | **1.20.0** |
296
- | `basalt/hand-rolled-plot` | 1.15.0 as `warn` | still `warn` — widened again at 1.20.1; grace restarts with the widening |
379
+ | `basalt/hand-rolled-plot` | 1.15.0 as `warn` | still `warn` — widened again at 1.21.0; grace restarts with the widening |
297
380
  | `basalt/chart-legend-literal` | 1.15.0 as `warn` | still `warn` — widened at 1.20.0 |
298
- | `basalt/shadow-basalt-export` | 1.20.0 as `warn` | may stay `warn` permanently — widened at 1.20.1 |
381
+ | `basalt/shadow-basalt-export` | 1.20.0 as `warn` | may stay `warn` permanently — widened at 1.21.0, narrowed at 1.21.1 |
299
382
  | `basalt/hand-rolled-shell` | 1.20.0 as `warn` | — |
300
383
 
301
384
  `card-with-border`, `inline-display`, `raw-html-layout`, `raw-form-control`, `raw-font-family` and
@@ -308,7 +391,7 @@ in the shipped preset). A config still naming `import-boundary` after 1.1.0 disa
308
391
 
309
392
  ## Deprecated, not yet removed
310
393
 
311
- The 32 camelCase `--vx-*` aliases deprecated in 1.5.0 are **still emitted at 1.20.1**.
394
+ The 32 camelCase `--vx-*` aliases deprecated in 1.5.0 are **still emitted at 1.21.0**.
312
395
  `buildPaletteCss({ legacyAliases: false })` / `tokens:css --no-legacy-aliases` opts out now; a later
313
396
  minor flips the default.
314
397
 
package/README.md CHANGED
@@ -224,7 +224,7 @@ Other keys (`exempt`, `severity`, `spacingSteps`, `forbiddenAccents`, …) are d
224
224
  **`exemptRules`** turns one kind off along a path. A pattern is a relative path
225
225
  (`public/site.webmanifest`, or `src/agent` for everything under it), a glob (`*` stops at `/`, `**`
226
226
  does not, and a slash-free glob is also tried against the basename, so `*.module.css` works), or a
227
- bare path segment — the legacy shape, and the one nobody guesses, since before 1.20.1 it was the
227
+ bare path segment — the legacy shape, and the one nobody guesses, since before 1.21.0 it was the
228
228
  ONLY shape and a real relative path matched nothing and said nothing. The object form records the
229
229
  reason, which is what a `theme-allow` always carried and this key could not. A pattern that
230
230
  suppresses nothing is now reported by a normal run and fails `--audit-allows`.
@@ -242,7 +242,7 @@ stylesheet `tokens:css` just wrote is not reported back at you, pasting the mark
242
242
  suppresses nothing, and an ordinary declaration smuggled into a sheet wearing the header is
243
243
  reported on its own line.
244
244
 
245
- **The escape hatch is scoped, and since 1.20.1 the two scopes are spelled apart:**
245
+ **The escape hatch is scoped, and since 1.21.0 the two scopes are spelled apart:**
246
246
 
247
247
  ```text
248
248
  theme-allow → this node/line, EVERY rule (reports theme-allow-unscoped)
@@ -252,7 +252,7 @@ theme-allow-file <id>[, <id>…] — <why> → the WHOLE FILE, those rules
252
252
  ```
253
253
 
254
254
  An annotation must **start** its comment (after `//`, `/*`, `<!--`, a block gutter `*`, or
255
- whitespace) — before 1.20.1 any comment merely mentioning the token parsed as the bare blanket form,
255
+ whitespace) — before 1.21.0 any comment merely mentioning the token parsed as the bare blanket form,
256
256
  so a file documenting its own waivers disarmed itself. Placements: the reported line, a comment-only
257
257
  line directly above it (the only form JSX can express, and it reaches the first CODE line below,
258
258
  walking through the rest of its comment block), or — in CSS — a trailing comment reaching back over
@@ -260,8 +260,13 @@ the declaration it terminates. The id slot is read strictly: a word there that n
260
260
  nothing, so a typo is never the blanket form.
261
261
 
262
262
  `basalt-ui check-theme --audit-allows` reports every annotation and every `exemptRules` entry with
263
- what it still suppresses, proved by re-running the guard with that one waiver neutralized. Exits 1
264
- on a dead waiver, so it is usable as a CI gate.
263
+ what it still suppresses, proved by re-running the scan with that one waiver neutralized. Exits 1 on
264
+ a dead waiver, so it is usable as a CI gate. Since 1.21.1 it runs **both** halves — `checkSource` for
265
+ a guard kind, oxlint over one neutralized sibling file for a plugin rule — so a waiver naming
266
+ `hand-rolled-plot` is judged rather than skipped. **It needs oxlint reachable**; where it is not, the
267
+ verdict is "cannot judge", never "dead". The report prints the scope it audited, because `0 dead`
268
+ over `basalt.roots` is not `0 dead anywhere`. Two known gaps: `basalt.exempt` is not audited at all,
269
+ and the `scoped to …` line does not distinguish `theme-allow` from `theme-allow-file`.
265
270
 
266
271
  `doctor`'s `ai-major-parity` hard check fails a monorepo where workspace packages declare different
267
272
  `ai` package majors — unless the split is intentional and written down. A producer pinned to an
@@ -294,7 +299,7 @@ deleted rather than silently surviving into the next real skew.
294
299
  | `./tokens` | **free** | `VX` token refs, `buildPaletteCss`, `defineSeries`, `seriesTokens`, `groupTokens`, `alpha`, `ColorPair` / `SeriesMap` types |
295
300
  | `./theme-lab` | coupled | `ThemeLabControls`, `applyOverrides`, `loadOverrides`, `COLOR_GROUPS` for live theme inspection |
296
301
  | `./vite` | — | `basaltViteConfig(opts)` — Vite preset for basalt-ui consumer apps; `basaltAppPlugin(opts)` — PWA head, manifest, and icon metadata derived from the token palette |
297
- | `./guard` | **free** | `checkSource`, `GUARD_RULES`, `Finding` types — the headless theme-guard core |
302
+ | `./guard` | **free** | `checkSource`, `GUARD_RULES`, `Finding` types — the headless theme-guard core; plus the annotation reader `--audit-allows` is built on: `findAllowAnnotations`, `neutralizeAllowAnnotation`, `NEUTRALIZED_ALLOW_TOKEN`, `PLUGIN_RULE_IDS`, `AllowAnnotationSite` |
298
303
  | `./query` | **free** | `createBasaltQueryClient`, transport-agnostic `unwrap`, lazy `BasaltQueryDevtools` |
299
304
  | `./router-tanstack` | **free** | TanStack Router bridge: `useBasaltNav` (active route) + `useRouterBreadcrumbs` |
300
305
  | `./forms` | coupled | Mantine form adapter: `useBasaltForm`, `field`, `FormErrorSummary`, `useFormDraft` (Standard Schema) |
@@ -689,7 +694,15 @@ honour `BASALT_CWD`, and relocate to the single workspace package carrying a bas
689
694
  invoked from a repo root that has none (two candidates is reported as ambiguous, never guessed).
690
695
  `tokens:css` and `fonts:css` both take `--check` as a CI drift gate.
691
696
 
692
- `sync` strategy per file: unchanged since last write → overwrite; locally edited → skip (show diff); missing → recreate. `--force` overwrites local edits.
697
+ `sync` strategy per file: unchanged since last write → overwrite; locally edited → skip (show diff);
698
+ missing → recreate. `--force` overwrites local edits. The summary counts `created` and `recreated`
699
+ apart: `recreated` means the ledger placed that file once and it went missing.
700
+
701
+ **`sync` refreshes an existing install; it does not create one.** It resolves its project exactly as
702
+ `check-theme` and `doctor` do, and since 1.21.1 **exits 1** when the resolved project has no
703
+ `.basalt/manifest.json`, naming the install it found above instead of scaffolding a second one
704
+ beside it. Run it at the package that holds the manifest, or point `BASALT_CWD` at that package.
705
+ Creating an install is `basalt-ui init`'s decision.
693
706
 
694
707
  ---
695
708
 
@@ -98,12 +98,17 @@ Run `basalt-ui doctor` afterwards to confirm the wiring took. A check that canno
98
98
 
99
99
  ### The lefthook preset overrides YOU, not the other way round
100
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.
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.
107
112
 
108
113
  **An `extends` target WINS on a colliding key.** Declare `pre-commit.commands.oxfmt.run` (or
109
114
  `glob:`) in your own file and the preset's value is what runs — yours is discarded silently. Only
@@ -160,7 +165,7 @@ site-wide OG/Twitter defaults from `options.seo`.
160
165
  plugins: [react(), ...basaltAppPlugin({ name: 'Argo', colorScheme: 'auto' })]
161
166
  ```
162
167
 
163
- Before 1.20.1 the anti-FOUC rule emitted `html{color-scheme:dark}` **unlayered**, which outranks
168
+ Before 1.21.0 the anti-FOUC rule emitted `html{color-scheme:dark}` **unlayered**, which outranks
164
169
  Mantine's own `@layer mantine` declaration regardless of specificity — a light-scheme consumer got
165
170
  dark scrollbars, dark `<select>` popups and dark date pickers permanently, with no opt-out. The rule
166
171
  is now scoped to `html:not([data-mantine-color-scheme])`, so it expires the instant MantineProvider
@@ -191,8 +196,9 @@ source image — it lives in the CONSUMER's own devDependencies, not basalt-ui's
191
196
  warns (does not fail) when `public/` exists but is missing one of these files.
192
197
 
193
198
  `basaltAppPlugin` also emits `site.webmanifest` (served in dev too) with explicit `id`/`scope`/
194
- `start_url` — pass `manifest: false` to skip it, or `icons: false` to skip the icon `<link>` tags
195
- without touching the manifest.
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.
196
202
 
197
203
  ## Service worker (opt-in)
198
204
 
@@ -60,7 +60,7 @@ radial/matrix shape, is not optional — two `basalt` oxlint plugin rules
60
60
  file went unpoliced. To waive ONE node, put `theme-allow hand-rolled-plot — <why>` on it (or on a
61
61
  comment-only line above it). A genuinely non-single-plot shape (multi-pane, radial, matrix)
62
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
63
+ anywhere in it; `DualPanel` carries the repo's only one. **`theme-allow-file` is the 1.21.0
64
64
  spelling** — at 1.20.0 a node-scoped annotation was silently promoted to a file declaration, so
65
65
  per-node scoping was not expressible; move the one word. The file that DEFINES `CartesianChart` is
66
66
  exempt definitionally (detected by declaration, not by path), since a rule saying "compose X"
@@ -95,7 +95,7 @@ ships no map kind.
95
95
  - **Want `@visx/geo` anyway?** Install it yourself — basalt doesn't pin it — and keep it under a
96
96
  `charts/` segment like every other visx import, same as any bespoke chart.
97
97
  - **Style map chrome with `--vx-*` tokens** (`VX.*`/`alpha()`) so it matches the rest of the app.
98
- - **The guard trap, narrowed at 1.20.1:** `check-theme`'s `inline-spacing` kind is a per-line regex
98
+ - **The guard trap, narrowed at 1.21.0:** `check-theme`'s `inline-spacing` kind is a per-line regex
99
99
  over raw source, not an AST pass. A **unitless** number now only reports inside a style-object
100
100
  context (a `style=`/`sx=`/`css=` attribute, or a const whose name or type says styles), so a map's
101
101
  pixel geometry — `fitBounds({ padding: 48 })`, marker offsets — no longer trips it: an options bag
@@ -83,7 +83,7 @@ against it.
83
83
  - Update via `navigate({ search: { window: 'all', from: undefined, to: undefined } })` — prefer plain
84
84
  objects over reducers to avoid `| undefined` assignability errors when the schema has defaults.
85
85
 
86
- ### Wire the reader, or the store does nothing (1.20.1)
86
+ ### Wire the reader, or the store does nothing (1.21.0)
87
87
 
88
88
  `validateSearch` is only half the store. It restores the value when you arrive with **no** param —
89
89
  it cannot do anything about a nav link that declares the param itself, and a link written
@@ -25,7 +25,7 @@ Pick the right home for each kind of state — don't dump everything into one st
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
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.
28
+ through that same primitive since 1.21.0; use `createPersistedState` for app-level preferences.
29
29
  Reach for a third-party store only when complex cross-component state genuinely warrants it (see
30
30
  escape hatch below).
31
31
 
@@ -67,7 +67,7 @@ const [layout, setLayout] = usePanelLayout()
67
67
  Keys are namespaced `basalt:<key>` automatically and never collide with the theme-scheme guard.
68
68
  Pass a `schema` (Standard Schema) to validate persisted values and fall back to `initial` on mismatch.
69
69
 
70
- ### Mirroring `BasaltShell`'s collapse (changed key at 1.20.1)
70
+ ### Mirroring `BasaltShell`'s collapse (changed key at 1.21.0)
71
71
 
72
72
  `BasaltShell` used `@mantine/hooks`' `useLocalStorage` while `createPersistedState` was the
73
73
  documented house API — so a consumer reading the raw key was complying with the shipped component,
@@ -77,11 +77,11 @@ moves the key:
77
77
  ```ts
78
78
  import { readPersistedValue } from 'basalt-ui/state'
79
79
 
80
- // 1.20.1: localStorage['basalt:<storageKey>'] === '{"v":1,"value":true}'
80
+ // 1.21.0: localStorage['basalt:<storageKey>'] === '{"v":1,"value":true}'
81
81
  const collapsed = readPersistedValue('basalt-sidebar-collapsed', 1)
82
82
  ```
83
83
 
84
- A one-time migration adopts the raw pre-1.20.1 value, so an upgrade does not silently re-expand
84
+ A one-time migration adopts the raw pre-1.21.0 value, so an upgrade does not silently re-expand
85
85
  every sidebar. It is a bridge, not a format — do not write against it.
86
86
 
87
87
  ## Zustand escape hatch (complex cross-component stores)
@@ -43,7 +43,7 @@ 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.1 contract
46
+ ## `theme-allow` — the 1.21.0 contract
47
47
 
48
48
  The full grammar, in both engines:
49
49
 
@@ -63,7 +63,7 @@ An id is a guard kind (`raw-surface`, `inline-spacing`, …) or an oxlint plugin
63
63
  ```
64
64
 
65
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
66
+ or nothing but whitespace. Before 1.21.0 any comment that merely _mentioned_ the token parsed as
67
67
  the bare blanket form and switched every rule off on the line below: linewatch documented its own
68
68
  waivers in a docblock and thereby disarmed the file. Prose about the escape hatch no longer waives
69
69
  anything; everything a consumer actually writes still qualifies.
@@ -71,7 +71,7 @@ An id is a guard kind (`raw-surface`, `inline-spacing`, …) or an oxlint plugin
71
71
  Plain `theme-allow` is the node/line waiver in both engines. At 1.20.0 the two halves of the
72
72
  contract intersected at exactly one legal shape and that shape was whole-file — naming a rule and
73
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.
74
+ ship. **This is the consumer break in 1.21.0**: move each existing file declaration one word.
75
75
  - **A bare `theme-allow` still waives everything on its placement** but reports
76
76
  `theme-allow-unscoped` (`warn` for one minor, then `error`). A bare `theme-allow-file` waives
77
77
  nothing at all — whole-file blanket immunity off one unnamed comment is the thing this contract
@@ -141,7 +141,12 @@ nothing is forked.
141
141
  - **Exact name match only** — the one limit that is structural. Round 5 confirmed the miss rate:
142
142
  linewatch's forks are named `Cell` and `Box`, rb's is `Stat`. Rename the fork and the rule goes
143
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
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
145
150
  `dist/index.d.ts` — so the `basalt-ui/charts` layer, which is where forks actually live, is finally
146
151
  covered. Names a barrel merely re-exports from a third party are skipped: `./charts` passes
147
152
  `Bar` / `Line` / `Pie` straight through from `@visx/shape`, and a local `Bar` is not a fork of
@@ -120,9 +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` 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
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
126
127
  rest. Ask before you build; do not wait to be told.
127
128
 
128
129
  The consumer series file (`<first basalt.root>/lib/series.ts` by default — so `src/lib/series.ts` on
@@ -213,7 +213,7 @@ import { ChartCursorScope } from 'basalt-ui/charts'
213
213
  rendered in a file that doesn't compose `CartesianChart`, per NODE. Escape:
214
214
  `theme-allow hand-rolled-plot — <why>` on the one node, or `theme-allow-file hand-rolled-plot —
215
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
216
+ `Donut`) declares itself. `-file` is the 1.21.0 spelling and it is required. The file that defines
217
217
  `CartesianChart` is exempt definitionally.
218
218
  - **No hand-authored legends.** `basalt/chart-legend-literal` fails the build on a hand-written
219
219
  array literal passed to `ChartLegend`'s `items` — derive it from the same `series` the chart draws
@@ -1332,11 +1332,37 @@ const chartLegendLiteral = {
1332
1332
  const PACKAGE_ROOT = resolvePath(PLUGIN_DIR, '..')
1333
1333
 
1334
1334
  /**
1335
- * A component-shaped name: starts uppercase and carries a lowercase letter, which excludes the
1335
+ * A component-shaped NAME: starts uppercase and carries a lowercase letter, which excludes the
1336
1336
  * SCREAMING_CASE constants and the two-letter token namespaces in the same barrel.
1337
+ *
1338
+ * A name test alone is not a component test — see {@link isComponentInit}. Every PascalCase binding
1339
+ * passes this, which is how obsidian's `SlugTracker` (a plain data class in a React-free package)
1340
+ * became a "forked component" the moment 1.21.0 widened the rule to all nine barrels.
1337
1341
  */
1338
1342
  const COMPONENT_NAME = /^[A-Z][A-Za-z0-9]*[a-z][A-Za-z0-9]*$/
1339
1343
 
1344
+ /** The two wrappers a function component is legitimately declared inside. */
1345
+ const COMPONENT_WRAPPERS = new Set(['memo', 'forwardRef'])
1346
+
1347
+ /**
1348
+ * Is this initializer component-SHAPED — a function, an arrow, or one wrapped in `memo` /
1349
+ * `forwardRef`?
1350
+ *
1351
+ * The second half of the fix for obsidian's false positive. `const Foo = new Map()` and
1352
+ * `class SlugTracker {}` share a name with a basalt export and nothing else; a fork of a component
1353
+ * is a function (or, historically, a class that `extends` one). Narrowing here costs the rule
1354
+ * nothing it was ever meant to catch and removes the whole "PascalCase binding" false-positive
1355
+ * class, which the nine-barrel widening made large.
1356
+ */
1357
+ function isComponentInit(init) {
1358
+ if (init === null || init === undefined) return false
1359
+ if (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression') return true
1360
+ if (init.type !== 'CallExpression') return false
1361
+ const callee = init.callee
1362
+ const name = callee?.type === 'MemberExpression' ? callee.property?.name : callee?.name
1363
+ return typeof name === 'string' && COMPONENT_WRAPPERS.has(name)
1364
+ }
1365
+
1340
1366
  /**
1341
1367
  * The barrels a consumer can import a component FROM. The root one is not the whole surface: a
1342
1368
  * chart-layer fork (`ChartFrame`, `CartesianChart`, `ChartCard`, …) collided with nothing, because
@@ -1428,6 +1454,11 @@ const shadowBasaltExport = {
1428
1454
  // basalt's own source DEFINES these names; a consumer's does not.
1429
1455
  if (BASALT_EXPORTS.size === 0 || filename.startsWith(`${PACKAGE_ROOT}/`)) return {}
1430
1456
  if (isTestFile(context)) return {}
1457
+ // Scoped exactly like `ai-sdk-major`, and for the same reason: a package with no basalt-ui
1458
+ // dependency (or one outside a declared `basalt.roots`) cannot import the export it is accused
1459
+ // of forking, so the advice — "import it from 'basalt-ui' instead" — is not followable there.
1460
+ // obsidian's React-free `obsidian-vault-core` took the report and had to waive it.
1461
+ if (filename.length === 0 || !isBasaltScopedFile(filename)) return {}
1431
1462
 
1432
1463
  const report = (node, name) => {
1433
1464
  if (typeof name !== 'string' || !BASALT_EXPORTS.has(name)) return
@@ -1440,9 +1471,14 @@ const shadowBasaltExport = {
1440
1471
  report(node, node.id?.name)
1441
1472
  },
1442
1473
  VariableDeclarator(node) {
1474
+ if (!isComponentInit(node.init)) return
1443
1475
  report(node, node.id?.name)
1444
1476
  },
1477
+ // A class only counts when it EXTENDS something — a legacy class component. basalt ships no
1478
+ // class components, so a standalone `class X {}` sharing a name with one of its exports is a
1479
+ // collision between two unrelated things, not a fork.
1445
1480
  ClassDeclaration(node) {
1481
+ if (node.superClass === null || node.superClass === undefined) return
1446
1482
  report(node, node.id?.name)
1447
1483
  },
1448
1484
  }