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.
Files changed (129) hide show
  1. package/AGENTS.md +19 -1
  2. package/CHANGELOG.md +39 -0
  3. package/MIGRATING.md +326 -0
  4. package/README.md +87 -9
  5. package/agent/rules/basalt-app.md +84 -0
  6. package/agent/rules/basalt-charts.md +32 -17
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-mantine.md +25 -10
  9. package/agent/rules/basalt-router.md +79 -15
  10. package/agent/rules/basalt-state.md +21 -3
  11. package/agent/rules/basalt-tokens.md +132 -6
  12. package/agent/skills/basalt-app/SKILL.md +45 -6
  13. package/agent/skills/basalt-charts/SKILL.md +5 -3
  14. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  15. package/configs/check.yml +11 -4
  16. package/configs/lefthook.yml +59 -7
  17. package/configs/oxlint-plugin.js +633 -30
  18. package/configs/oxlint-plugin.test.ts +594 -15
  19. package/configs/oxlint.json +10 -1
  20. package/dist/agent/stick-to-bottom.js +1 -1
  21. package/dist/agent-chat/thread-detail-panel.js +1 -1
  22. package/dist/agent-chat/thread-message.js +2 -2
  23. package/dist/agent-chat/tool-chip.js +1 -1
  24. package/dist/charts/kinds/Bars.d.ts +1 -1
  25. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  26. package/dist/charts/kinds/Donut.d.ts +1 -1
  27. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  28. package/dist/charts/kinds/Donut.js +1 -1
  29. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  30. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  31. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  32. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  33. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  34. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  35. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  36. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  37. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  38. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  39. package/dist/charts/primitives/ChartLegend.js +2 -2
  40. package/dist/charts/primitives/ChartTooltip.js +1 -1
  41. package/dist/cli/index.d.ts +146 -20
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +901 -101
  44. package/dist/cli/toolchain-wiring.test.d.ts +2 -0
  45. package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
  46. package/dist/cli/waiver-audit.test.d.ts +2 -0
  47. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  48. package/dist/dashboard/delta-badge.js +2 -2
  49. package/dist/dashboard/stat-card.js +1 -1
  50. package/dist/data/virtual-list.js +5 -5
  51. package/dist/guard/index.d.ts +101 -2
  52. package/dist/guard/index.d.ts.map +1 -1
  53. package/dist/guard/index.js +474 -62
  54. package/dist/guard/types.d.ts +25 -5
  55. package/dist/guard/types.d.ts.map +1 -1
  56. package/dist/register.d.ts +25 -0
  57. package/dist/register.d.ts.map +1 -1
  58. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  59. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  60. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  61. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  62. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  63. package/dist/router-tanstack/search-param-store.js +31 -3
  64. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  65. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  66. package/dist/shell/app-mobile-nav.module.css +11 -3
  67. package/dist/shell/index.d.ts +12 -5
  68. package/dist/shell/index.d.ts.map +1 -1
  69. package/dist/shell/index.js +24 -6
  70. package/dist/surfaces.d.ts +1 -1
  71. package/dist/surfaces.js +1 -1
  72. package/dist/tokens/index.d.ts +5 -3
  73. package/dist/tokens/index.d.ts.map +1 -1
  74. package/dist/tokens/index.js +7 -1
  75. package/dist/tokens/palette.d.ts +35 -27
  76. package/dist/tokens/palette.d.ts.map +1 -1
  77. package/dist/tokens/palette.js +24 -18
  78. package/dist/tokens/text-scale.test.d.ts +2 -0
  79. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  80. package/dist/tokens.css +21 -17
  81. package/dist/vite-color-mix.js +2 -2
  82. package/dist/vite.d.ts +24 -2
  83. package/dist/vite.d.ts.map +1 -1
  84. package/dist/vite.js +54 -13
  85. package/llms.txt +3 -3
  86. package/package.json +4 -3
  87. package/src/agent/stick-to-bottom.tsx +1 -1
  88. package/src/agent-chat/thread-detail-panel.tsx +1 -1
  89. package/src/agent-chat/thread-message.tsx +3 -3
  90. package/src/agent-chat/tool-chip.tsx +1 -1
  91. package/src/charts/kinds/Bars.tsx +1 -1
  92. package/src/charts/kinds/Donut.tsx +3 -2
  93. package/src/charts/kinds/DualPanel.tsx +2 -2
  94. package/src/charts/kinds/Heatmap.tsx +1 -1
  95. package/src/charts/kinds/MultiLine.tsx +1 -1
  96. package/src/charts/kinds/StackedArea.tsx +1 -1
  97. package/src/charts/kinds/ZonedLine.tsx +1 -1
  98. package/src/charts/primitives/ChartLegend.tsx +2 -2
  99. package/src/charts/primitives/ChartTooltip.tsx +1 -1
  100. package/src/cli/index.ts +1614 -164
  101. package/src/cli/init-sync.test.ts +21 -3
  102. package/src/cli/template-ledger.test.ts +1 -1
  103. package/src/cli/tokens-css.test.ts +27 -14
  104. package/src/cli/toolchain-wiring.test.ts +856 -0
  105. package/src/cli/waiver-audit.test.ts +131 -0
  106. package/src/dashboard/delta-badge.tsx +3 -3
  107. package/src/dashboard/stat-card.tsx +1 -1
  108. package/src/data/virtual-list.tsx +8 -8
  109. package/src/guard/check-source.test.ts +1104 -12
  110. package/src/guard/index.ts +1106 -103
  111. package/src/guard/types.ts +29 -4
  112. package/src/register.ts +32 -0
  113. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  114. package/src/router-tanstack/search-param-store.test.ts +135 -0
  115. package/src/router-tanstack/search-param-store.ts +90 -8
  116. package/src/shell/app-mobile-nav.module.css +11 -3
  117. package/src/shell/app-mobile-nav.tsx +1 -1
  118. package/src/shell/index.test.tsx +52 -0
  119. package/src/shell/index.tsx +64 -13
  120. package/src/surfaces.ts +1 -1
  121. package/src/theme/density-relations.test.ts +8 -5
  122. package/src/theme/spacing.test.ts +2 -0
  123. package/src/theme-toggle/index.tsx +2 -2
  124. package/src/tokens/index.ts +28 -3
  125. package/src/tokens/palette.ts +24 -18
  126. package/src/tokens/text-scale.test.ts +51 -0
  127. package/src/vite-color-mix.ts +2 -2
  128. package/src/vite.test.ts +125 -6
  129. 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` fails the build. Escape: a `theme-allow` comment on the first such
59
- site — that is how a genuinely non-single-plot shape (multi-pane, radial, matrix) declares
60
- itself; `DualPanel` carries the repo's one such comment. The file that DEFINES `CartesianChart`
61
- is exempt definitionally (detected by declaration, not by path), since a rule saying "compose X"
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`** — passing a hand-written array literal to `ChartLegend`'s
64
- `items` fails the build; the legend must be derived from the same `series` array the chart draws
65
- (`deriveLegend`, or just let `ChartFrame`/`CartesianChart` do it), so it cannot go stale and keep
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 at `warn` in the consumer preset (`configs/oxlint.json`) for one minor and `error`
69
- repo-local, per the "Shipping a stricter guard — the grace minor" doctrine in
70
- `packages/basalt-ui/CLAUDE.md`. They promote to `error` in the next minor.
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 over raw source, not
85
- an AST pass — it flags any `padding:`/`gap:`/`margin:` followed by a number in _any_ object
86
- literal, in any file. A map's pixel geometry (`fitBounds({ padding: 48 })`, marker offsets) trips
87
- it even though it isn't spacing, and hoisting the value to a module-scope const doesn't help — the
88
- regex sees the line, not the binding. Use a `theme-allow` line comment; that's the sanctioned
89
- answer, not a bug to file.
98
+ - **The guard trap, narrowed at 1.20.1:** `check-theme`'s `inline-spacing` kind is a per-line regex
99
+ over raw source, not an AST pass. A **unitless** number now only reports inside a style-object
100
+ context (a `style=`/`sx=`/`css=` attribute, or a const whose name or type says styles), so a map's
101
+ pixel geometry — `fitBounds({ padding: 48 })`, marker offsets — no longer trips it: an options bag
102
+ is not CSS. A value carrying a unit is CSS wherever it was written and still reports. Where it
103
+ does fire on genuine map chrome, scope a `theme-allow inline-spacing — map geometry` comment to
104
+ it; that's the sanctioned answer, not a bug to file.
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` exemption.
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
- - **Mechanical enforcement.** `basalt-ui check-theme` adds six surface guard kinds (each a config knob,
153
- default ON; `theme-allow` line-comment escape): `off-system-surface-var` (raw ramp-step vars),
154
- `card-with-border` (`withBorder` on a `Card`/`Paper`), `mantine-shade-index` (a shade-pinned color —
155
- see below), `raw-html-layout` (raw `<div>`/`<span>` with inline layout/surface styling),
156
- `inline-spacing` (inline spacing literals), `inline-display` (inline `display` literals). The
157
- Mantine-free `src/charts/**` is the only place raw `<div>` is allowed — and it must still use `VX.*`
158
- tokens. `oxlint` adds `basalt/card-inset`, which catches the other half of the same card drift: an
159
- explicit `p`/`padding`/`radius` on a `Card`/`Paper` instead of the xs/sm inset and the theme radius.
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. When a fully custom layout is needed, the shell
282
- sub-components are available individually from `basalt-ui`:
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
- - `@tanstack/router-plugin/vite` must be configured in your Vite config and `tsr generate` must
108
- have run at least once — the `StaticDataRouteOption` augmentation only takes effect once the route
109
- tree (`src/routeTree.gen.ts`) has been generated. In CI and after adding routes, run
110
- `tsr generate` (or `bun run typecheck`, which triggers it) before relying on breadcrumb or nav data.
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
- link: linkOptions({ to: '/garmin-health', search: { window: '30d' } }),
226
+ // NOT `search: { window: '30d' }` — a module-scope literal pins the fallback on
227
+ // every click and silently overrides the store. Pass the reader by reference.
228
+ link: linkOptions({ to: '/garmin-health', search: garminWindow.linkSearch }),
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** — `search: () => ({ date: format(new Date(), 'yyyy-MM-dd') })`
201
- compiles without `as const` and re-evaluates at click time, so "today" never goes stale in a
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: { window: '30d' } }
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: TypeScript cannot catch a destination that FORGETS a
261
- default `search` when the route validates with `validateSearch: (raw: Record<string, unknown>) => T`
262
- — that gives TanStack an input type where no key is required. Diff an old hand-written target table
263
- against the new definition by hand, key by key.
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` already persists its own collapse state
28
- internally; use `createPersistedState` for app-level preferences. Reach for a third-party store
29
- only when complex cross-component state genuinely warrants it (see escape hatch below).
27
+ versioned localStorage primitive (see below). `BasaltShell` persists its own collapse state
28
+ through that same primitive since 1.20.1; use `createPersistedState` for app-level preferences.
29
+ Reach for a third-party store only when complex cross-component state genuinely warrants it (see
30
+ escape hatch below).
30
31
 
31
32
  ## useOnlineStatus — SSR-safe online/offline hook
32
33
 
@@ -66,6 +67,23 @@ const [layout, setLayout] = usePanelLayout()
66
67
  Keys are namespaced `basalt:<key>` automatically and never collide with the theme-scheme guard.
67
68
  Pass a `schema` (Standard Schema) to validate persisted values and fall back to `initial` on mismatch.
68
69
 
70
+ ### Mirroring `BasaltShell`'s collapse (changed key at 1.20.1)
71
+
72
+ `BasaltShell` used `@mantine/hooks`' `useLocalStorage` while `createPersistedState` was the
73
+ documented house API — so a consumer reading the raw key was complying with the shipped component,
74
+ not drifting from it. It now stores through `createPersistedState`, which makes it SSR-safe and
75
+ moves the key:
76
+
77
+ ```ts
78
+ import { readPersistedValue } from 'basalt-ui/state'
79
+
80
+ // 1.20.1: localStorage['basalt:<storageKey>'] === '{"v":1,"value":true}'
81
+ const collapsed = readPersistedValue('basalt-sidebar-collapsed', 1)
82
+ ```
83
+
84
+ A one-time migration adopts the raw pre-1.20.1 value, so an upgrade does not silently re-expand
85
+ every sidebar. It is a bridge, not a format — do not write against it.
86
+
69
87
  ## Zustand escape hatch (complex cross-component stores)
70
88
 
71
89
  Reach for Zustand only when `createPersistedState` is insufficient — typically when multiple
@@ -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`). A violation fails the build. Escape hatch: a
28
- `theme-allow` line comment (diff-visible, deliberate).
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` to your source dirs and `exempt`
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 (e.g. a third-party widget needing a literal). The palette-definition
162
- files are listed in your `exempt` set so they don't self-trip.
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.