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
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Guard types — Mantine-free, dependency-free (zero imports beyond TS types).
3
3
  *
4
- * GuardKind is the closed set of 20 violation kinds the theme guard can emit.
4
+ * GuardKind is the closed set of 25 violation kinds the theme guard can emit.
5
5
  * Finding is the structured result per violation, replacing the old `Violation` shape.
6
6
  * GuardConfig is the per-run configuration that drives checkSource.
7
7
  */
8
8
 
9
- /** The 20 theme-guard violation kinds. */
9
+ /** The 25 theme-guard violation kinds. */
10
10
  export type GuardKind =
11
11
  | 'raw-hex'
12
12
  | 'raw-color-fn'
@@ -28,6 +28,11 @@ export type GuardKind =
28
28
  | 'raw-form-control'
29
29
  | 'sub-16-input-font'
30
30
  | 'raw-font-family'
31
+ | 'theme-allow-unscoped'
32
+ | 'surface-shadow-override'
33
+ | 'css-raw-surface'
34
+ | 'inline-font-size'
35
+ | 'hidden-inline-style'
31
36
 
32
37
  /**
33
38
  * How hard a finding lands. `error` fails the build; `warn` reports and passes.
@@ -151,10 +156,30 @@ export type GuardConfig = {
151
156
  */
152
157
  readonly sub16InputFont: boolean
153
158
  /**
154
- * Allow-comment policy: a line containing this substring is skipped entirely.
155
- * Default 'theme-allow'. Pure-comment lines (// * /\*) are always skipped.
159
+ * Allow-comment policy. The token that opens an exception annotation inside a COMMENT, on the
160
+ * finding's own line or the line directly above it (matching the oxlint plugin's placement, and
161
+ * surviving an `oxfmt` reflow that moves the comment off the offending line). Default
162
+ * `'theme-allow'`.
163
+ *
164
+ * The accountable form is `theme-allow <rule-id>[, <rule-id>] — <reason>`: the rule ids scope the
165
+ * exception to those kinds only, and the reason says why. A `theme-allow` missing either half
166
+ * still suppresses (so an upgrade never breaks a build over it) but reports
167
+ * {@link GuardKind} `theme-allow-unscoped` on its own line.
156
168
  */
157
169
  readonly allowComment: string
170
+ /**
171
+ * Which kinds apply to this consumer. `'mantine'` (default) is the full framework guard.
172
+ *
173
+ * `'tokens-only'` is for a consumer that installs the TOKEN LAYER and nothing else — no
174
+ * `@mantine/core`, often no React (`basalt-ui tokens:css` + `basalt-ui/tokens`). Every kind whose
175
+ * remedy is a Mantine component or prop is disabled there, because "use `TextInput` from
176
+ * `@mantine/core`" is not advice, it is a dependency the consumer deliberately does not have.
177
+ * The color/typography kinds stay on — those are the tokens, which is the whole surface they DO
178
+ * consume. See `TOKENS_ONLY_DISABLED_KINDS` in `./index` for the exact set.
179
+ *
180
+ * Optional so a caller built before this field existed keeps compiling; absent means `'mantine'`.
181
+ */
182
+ readonly profile?: 'mantine' | 'tokens-only'
158
183
  /**
159
184
  * Per-rule, per-path exemptions — complements whole-file `exempt` (BasaltConfig, which skips
160
185
  * ALL rules for a file) and code-level `appliesTo` (hardcoded per-kind path scoping, e.g.
package/src/register.ts CHANGED
@@ -156,3 +156,35 @@ export namespace StandardSchemaV1 {
156
156
  T['~standard']['types']
157
157
  >['output']
158
158
  }
159
+
160
+ // ── Ambient app globals ───────────────────────────────────────────────────────────────────────────
161
+
162
+ declare global {
163
+ /**
164
+ * The app's version string, injected as a build-time constant by basalt's own Vite preset —
165
+ * `basaltViteConfig` defines it from `BUILD_VERSION`, falling back to the app package.json
166
+ * `version` (see `../vite`, the `define` block).
167
+ *
168
+ * Declared HERE rather than beside the define because a consumer's `src/**` never loads
169
+ * `basalt-ui/vite`'s types (that subpath is imported from `vite.config.ts`, which most apps keep
170
+ * in a separate tsconfig project). This file is re-exported from the root barrel, so an app that
171
+ * imports from `'basalt-ui'` at all has it in scope with no `/// <reference>` and no
172
+ * `src/vite-env.d.ts` entry — which is the point: basalt shipped the define and no declaration,
173
+ * so every consumer hand-wrote this block, one of them needing an `oxlint-disable` in a file that
174
+ * existed only because basalt asked for it.
175
+ *
176
+ * A consumer importing ONLY a subpath (`basalt-ui/tokens`, `basalt-ui/charts`) does not load this
177
+ * file and does not get the global. That gap is the same set as the applicability gap: reaching
178
+ * for the root barrel is what `BasaltProvider` requires, and an app that never does is not on
179
+ * basalt's Vite preset either, so there is no `__APP_VERSION__` to declare.
180
+ *
181
+ * Precondition: only defined when your Vite config actually goes through `basaltViteConfig` (or
182
+ * you define it yourself). An app that hand-rolls its Vite config and reads this gets a
183
+ * ReferenceError at runtime — TypeScript is trusting the preset, not proving it.
184
+ */
185
+ // The dangle IS the API: this declares the build-time constant basalt's own Vite preset defines,
186
+ // so the name is not ours to change. The SHIPPED preset allows it (configs/oxlint.json); this
187
+ // repo's own root .oxlintrc.json does not, hence the local suppression.
188
+ // oxlint-disable-next-line no-underscore-dangle
189
+ const __APP_VERSION__: string
190
+ }
@@ -8,6 +8,7 @@
8
8
  * 2. `useStore` — React hook: `const [values, persist] = store.useStore()`.
9
9
  * Call `persist(next)` alongside `navigate` so the selection survives navigation.
10
10
  * 3. `readStored` — plain function for non-React contexts (tests, guards).
11
+ * 4. `linkSearch` — the click-time `search:` thunk for a nav link, ready-made.
11
12
  *
12
13
  * Headless — no Mantine, no JSX. Same tier as `createSearchParamStore`.
13
14
  *
@@ -77,6 +78,16 @@ export type MultiSearchParamStore<T extends string, P extends string = string> =
77
78
  useStore: () => readonly [readonly T[], (next: readonly T[]) => void]
78
79
  /** Plain read — for use outside React (tests, guards, fallback reads). */
79
80
  readStored: () => readonly T[] | null
81
+ /**
82
+ * The click-time `search:` thunk for a nav link — `() => ({ [param]: readStored() ?? fallback })`.
83
+ * Pass it BY REFERENCE (`search: store.linkSearch`), never call it; see `createSearchParamStore`
84
+ * for why a module-scope literal silently defeats the store.
85
+ *
86
+ * No dev warning rides on this one, unlike the single-value store: an empty array in the URL is
87
+ * indistinguishable from an absent param here (`validateSearch` falls back to storage for both),
88
+ * so a link declaring `{ tags: [] }` restores correctly and there is no broken state to detect.
89
+ */
90
+ linkSearch: () => { [K in P]: readonly T[] }
80
91
  }
81
92
 
82
93
  // ── Implementation ─────────────────────────────────────────────────────────
@@ -104,6 +115,10 @@ export function createMultiSearchParamStore<const T extends string, const P exte
104
115
  return normalize(raw)
105
116
  }
106
117
 
118
+ const linkSearch = (): { [K in P]: readonly T[] } => {
119
+ return { [opts.param]: readStored() ?? fallback } as { [K in P]: readonly T[] }
120
+ }
121
+
107
122
  const validateSearch = (search: Record<string, unknown>): { [K in P]: readonly T[] } => {
108
123
  const fromUrl = normalize(search[opts.param])
109
124
  if (fromUrl.length > 0) {
@@ -118,5 +133,5 @@ export function createMultiSearchParamStore<const T extends string, const P exte
118
133
  initial: fallback,
119
134
  })
120
135
 
121
- return { validateSearch, useStore, readStored }
136
+ return { validateSearch, useStore, readStored, linkSearch }
122
137
  }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `createSearchParamStore`'s persistence seam — the half that was invisible.
3
+ *
4
+ * The reference consumer adopted the store in three features, hand-rolled the persistence in all
5
+ * three, and the reader had ZERO call sites: "remember my window" had never once worked, and
6
+ * nothing in the type, the runtime or the tests said so. The 5-step recipe in the JSDoc was
7
+ * present the whole time. So the fix is API-shaped (`linkSearch`) plus a dev warning that fires in
8
+ * exactly the broken state — and these tests are what keep both honest.
9
+ */
10
+ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'
11
+ import { createSearchParamStore } from './search-param-store'
12
+
13
+ const VALUES = ['1d', '7d', '30d'] as const
14
+
15
+ function makeStore(key: string) {
16
+ return createSearchParamStore({ key, param: 'range', values: VALUES, fallback: '30d' })
17
+ }
18
+
19
+ /** Writes the `createPersistedState` envelope directly — the shape `readStored` parses. */
20
+ function persist(key: string, value: string, version = 1): void {
21
+ localStorage.setItem(`basalt:${key}`, JSON.stringify({ v: version, value }))
22
+ }
23
+
24
+ let warn: ReturnType<typeof spyOn<Console, 'warn'>>
25
+
26
+ beforeEach(() => {
27
+ localStorage.clear()
28
+ warn = spyOn(console, 'warn').mockImplementation(() => {})
29
+ })
30
+
31
+ afterEach(() => {
32
+ warn.mockRestore()
33
+ })
34
+
35
+ describe('linkSearch', () => {
36
+ it('is the click-time thunk — it reads storage at CALL time, not at creation', () => {
37
+ const store = makeStore('link-thunk')
38
+ expect(store.linkSearch()).toEqual({ range: '30d' })
39
+
40
+ persist('link-thunk', '7d')
41
+ // A value captured at module scope would still say 30d here. That is the bug it prevents.
42
+ expect(store.linkSearch()).toEqual({ range: '7d' })
43
+ })
44
+
45
+ it('falls back when nothing valid is stored', () => {
46
+ persist('link-fallback', 'not-a-range')
47
+ expect(makeStore('link-fallback').linkSearch()).toEqual({ range: '30d' })
48
+ })
49
+
50
+ it('returns the param under the store’s own param name', () => {
51
+ const store = createSearchParamStore({
52
+ key: 'link-param',
53
+ param: 'window',
54
+ values: VALUES,
55
+ fallback: '1d',
56
+ })
57
+ persist('link-param', '7d')
58
+ expect(store.linkSearch()).toEqual({ window: '7d' })
59
+ })
60
+ })
61
+
62
+ describe('the unwired-reader warning', () => {
63
+ it('fires when a link pins the fallback over a different stored value', () => {
64
+ const store = makeStore('warn-pinned')
65
+ persist('warn-pinned', '7d')
66
+
67
+ expect(store.validateSearch({ range: '30d' })).toEqual({ range: '30d' })
68
+ expect(warn).toHaveBeenCalledTimes(1)
69
+ expect(String(warn.mock.calls[0]?.[0])).toContain('linkSearch')
70
+ })
71
+
72
+ it('fires at most once per store, so a nav-heavy app is not spammed', () => {
73
+ const store = makeStore('warn-once')
74
+ persist('warn-once', '7d')
75
+
76
+ store.validateSearch({ range: '30d' })
77
+ store.validateSearch({ range: '30d' })
78
+ store.validateSearch({ range: '30d' })
79
+ expect(warn).toHaveBeenCalledTimes(1)
80
+ })
81
+
82
+ it('stays silent once a reader is wired — that is the whole point', () => {
83
+ const store = makeStore('warn-wired')
84
+ persist('warn-wired', '7d')
85
+
86
+ store.linkSearch()
87
+ store.validateSearch({ range: '30d' })
88
+ expect(warn).not.toHaveBeenCalled()
89
+ })
90
+
91
+ it('stays silent for a deliberate deep link to a non-fallback value', () => {
92
+ const store = makeStore('warn-deeplink')
93
+ persist('warn-deeplink', '30d')
94
+
95
+ store.validateSearch({ range: '7d' })
96
+ expect(warn).not.toHaveBeenCalled()
97
+ })
98
+
99
+ it('stays silent when nothing is stored yet', () => {
100
+ makeStore('warn-empty').validateSearch({ range: '30d' })
101
+ expect(warn).not.toHaveBeenCalled()
102
+ })
103
+
104
+ it('never fires from validateSearch’s own internal read', () => {
105
+ // validateSearch reads the store on every navigation; that must not count as "wired", and it
106
+ // must not itself trigger the warning when the URL carries no param at all.
107
+ const store = makeStore('warn-internal')
108
+ persist('warn-internal', '7d')
109
+
110
+ expect(store.validateSearch({})).toEqual({ range: '7d' })
111
+ expect(warn).not.toHaveBeenCalled()
112
+ // Still unwired, so the pinned-link case is still detectable afterwards.
113
+ store.validateSearch({ range: '30d' })
114
+ expect(warn).toHaveBeenCalledTimes(1)
115
+ })
116
+ })
117
+
118
+ describe('validateSearch (unchanged behaviour)', () => {
119
+ it('prefers a valid URL param, then storage, then the fallback', () => {
120
+ const store = makeStore('validate')
121
+ expect(store.validateSearch({ range: '7d' })).toEqual({ range: '7d' })
122
+
123
+ persist('validate', '1d')
124
+ expect(store.validateSearch({})).toEqual({ range: '1d' })
125
+ expect(store.validateSearch({ range: 'bogus' })).toEqual({ range: '1d' })
126
+
127
+ localStorage.clear()
128
+ expect(store.validateSearch({})).toEqual({ range: '30d' })
129
+ })
130
+
131
+ it('discards an envelope written under a different version', () => {
132
+ persist('validate-version', '7d', 2)
133
+ expect(makeStore('validate-version').readStored()).toBeNull()
134
+ })
135
+ })
@@ -9,6 +9,19 @@
9
9
  * Call `persist(next)` alongside `navigate` so the selection survives
10
10
  * navigation away from and back to the route.
11
11
  * 3. `readStored` — plain function for non-React contexts (tests, guards).
12
+ * 4. `linkSearch` — the click-time `search:` thunk a nav link needs, ready-made.
13
+ *
14
+ * ## The half that is easy to skip
15
+ *
16
+ * `validateSearch` restores the value when you navigate TO the route with no param. It cannot do
17
+ * anything about a nav link that declares the param itself — and a link written as
18
+ * `search: { range: '30d' }` at module scope pins the fallback on every click, which silently
19
+ * defeats the whole store. The reference consumer shipped exactly that: three features adopted the
20
+ * persistence, the reader had ZERO call sites, and "remember my window" had never once worked.
21
+ *
22
+ * `linkSearch` exists so that failure is unreachable from the API rather than only warned about in
23
+ * this comment — the mistake is made while typing `search:`, so the remedy has to be something
24
+ * autocomplete offers at that keystroke. The JSDoc was already here when it happened.
12
25
  *
13
26
  * Headless — no Mantine, no JSX. Same tier as `useBasaltNav` and
14
27
  * `useRouterBreadcrumbs`.
@@ -51,17 +64,18 @@
51
64
  *
52
65
  * **5. Nav links** — carry the param across sub-pages, set per destination in
53
66
  * the nav definition so only the dashboard sub-tree inherits it and every
54
- * other link stays clean. Use a CLICK-TIME THUNK over `readStored`:
55
- *
56
- * const dashSearch = () => ({ range: dashboardRange.readStored() ?? '30d' })
67
+ * other link stays clean. Pass `linkSearch` BY REFERENCE — it is the click-time thunk:
57
68
  *
58
69
  * navGroup({ id: 'dash', label: 'Dashboard' }, [
59
70
  * { id: 'dash-overview', label: 'Overview', icon: <IconHome />,
60
- * link: linkOptions({ to: '/dashboard', search: dashSearch }) },
71
+ * link: linkOptions({ to: '/dashboard', search: dashboardRange.linkSearch }) },
61
72
  * { id: 'dash-activity', label: 'Activity', icon: <IconPulse />,
62
- * link: linkOptions({ to: '/dashboard/activity', search: dashSearch }) },
73
+ * link: linkOptions({ to: '/dashboard/activity', search: dashboardRange.linkSearch }) },
63
74
  * ])
64
75
  *
76
+ * `linkSearch` IS that thunk (`() => ({ [param]: readStored() ?? fallback })`) — hand-rolling it
77
+ * over `readStored` still works and is what you need when the link also carries other params.
78
+ *
65
79
  * NOT `search: true`. That flag means "keep the current search" and TanStack
66
80
  * only offers it where the target's search is OPTIONAL — a route wired to a
67
81
  * store's `validateSearch` always returns the param, so the router requires a
@@ -101,6 +115,42 @@ export type SearchParamStore<T extends string, P extends string = string> = {
101
115
  useStore: () => readonly [T, (next: T) => void]
102
116
  /** Plain read — for use outside React (tests, guards, fallback reads). */
103
117
  readStored: () => T | null
118
+ /**
119
+ * The click-time `search:` thunk for a nav link — `() => ({ [param]: readStored() ?? fallback })`.
120
+ *
121
+ * Pass it BY REFERENCE (`search: store.linkSearch`), never call it: `<Link>` re-evaluates the
122
+ * thunk on every click, so arriving from outside the sub-tree restores the last selection,
123
+ * whereas a value computed once at module scope goes stale immediately and pins the fallback.
124
+ */
125
+ linkSearch: () => { [K in P]: T }
126
+ }
127
+
128
+ /**
129
+ * Dev-only, once per store: the URL is pinning `fallback` while something else is persisted, and
130
+ * neither reader has ever been called. That combination has exactly one realistic cause — a nav
131
+ * link declaring the param as a literal — and it is invisible otherwise, because every individual
132
+ * piece looks correct and the feature merely never remembers anything.
133
+ *
134
+ * Deliberately NOT a timer over "was this store ever read": that fires in every test that builds a
135
+ * store, and it cannot say what to do about it. This fires only in the broken state, and names the
136
+ * fix.
137
+ */
138
+ function warnLinkPinsFallback(input: {
139
+ key: string
140
+ param: string
141
+ urlValue: string
142
+ storedValue: string
143
+ }): void {
144
+ const { key, param, urlValue, storedValue } = input
145
+ // oxlint-disable-next-line no-console -- a dev-time wiring warning has no other channel
146
+ console.warn(
147
+ `[basalt-ui] createSearchParamStore('${key}'): the URL pinned \`${param}=${urlValue}\` ` +
148
+ '(the fallback) ' +
149
+ `while '${storedValue}' was persisted, and nothing has read this store back. A nav link is ` +
150
+ `almost certainly declaring \`search: { ${param}: '${urlValue}' }\` at module scope, which ` +
151
+ "overrides the stored value on every click — pass the store's own reader instead: " +
152
+ '`search: <store>.linkSearch`. (dev only)',
153
+ )
104
154
  }
105
155
 
106
156
  // ── Implementation ─────────────────────────────────────────────────────────
@@ -110,19 +160,51 @@ export function createSearchParamStore<const T extends string, const P extends s
110
160
  ): SearchParamStore<T, P> {
111
161
  const version = opts.version ?? 1
112
162
 
113
- const readStored = (): T | null => {
163
+ // `validateSearch` reads the store internally on every navigation, which says nothing about
164
+ // whether the CONSUMER wired a reader — only the two public readers below set this.
165
+ let readerWired = false
166
+ let warned = false
167
+
168
+ const read = (): T | null => {
114
169
  const raw = readPersistedValue(opts.key, version)
115
170
  if (typeof raw !== 'string') return null
116
171
  if ((opts.values as readonly string[]).includes(raw)) return raw as T
117
172
  return null
118
173
  }
119
174
 
175
+ const readStored = (): T | null => {
176
+ readerWired = true
177
+ return read()
178
+ }
179
+
180
+ const linkSearch = (): { [K in P]: T } => {
181
+ readerWired = true
182
+ return { [opts.param]: (read() ?? opts.fallback) as T } as { [K in P]: T }
183
+ }
184
+
120
185
  const validateSearch = (search: Record<string, unknown>): { [K in P]: T } => {
121
186
  const raw = search[opts.param]
122
187
  if (typeof raw === 'string' && (opts.values as readonly string[]).includes(raw)) {
188
+ if (
189
+ !warned &&
190
+ !readerWired &&
191
+ raw === opts.fallback &&
192
+ process.env['NODE_ENV'] !== 'production'
193
+ ) {
194
+ const stored = read()
195
+ if (stored !== null && stored !== raw) {
196
+ warned = true
197
+ warnLinkPinsFallback({
198
+ key: opts.key,
199
+ param: opts.param,
200
+ urlValue: raw,
201
+ storedValue: stored,
202
+ })
203
+ }
204
+ }
123
205
  return { [opts.param]: raw as T } as { [K in P]: T }
124
206
  }
125
- return { [opts.param]: (readStored() ?? opts.fallback) as T } as { [K in P]: T }
207
+ return { [opts.param]: (read() ?? opts.fallback) as T } as { [K in P]: T }
126
208
  }
127
209
 
128
210
  const useStore = createPersistedState<T>({
@@ -131,5 +213,5 @@ export function createSearchParamStore<const T extends string, const P extends s
131
213
  initial: opts.fallback,
132
214
  })
133
215
 
134
- return { validateSearch, useStore, readStored }
216
+ return { validateSearch, useStore, readStored, linkSearch }
135
217
  }
@@ -46,9 +46,17 @@
46
46
  display: inline-flex;
47
47
  align-items: center;
48
48
  justify-content: center;
49
- /* Deliberately sub-scale: the active pill hugs a 24px glyph, so it is a shape inset rather than
50
- * layout spacing — the xs..xl scale has no step this tight. */
51
- padding: 2px 12px; /* theme-allow */
49
+ /* This span IS the active pill, so its box may not depend on what it contains: shipping no icon
50
+ * dependency is a supported configuration, and an empty span collapsed the pill to a 24x4px
51
+ * dash. The floor is the icon box PLUS its inset — everything is border-box under Mantine's
52
+ * reset — so the pill renders identically with and without a glyph, at every density. */
53
+ min-width: calc(
54
+ var(--vx-space-mobile-nav-icon-size) + 2 * var(--vx-space-mobile-nav-tab-inset-x)
55
+ );
56
+ min-height: calc(
57
+ var(--vx-space-mobile-nav-icon-size) + 2 * var(--vx-space-mobile-nav-tab-inset-y)
58
+ );
59
+ padding: var(--vx-space-mobile-nav-tab-inset-y) var(--vx-space-mobile-nav-tab-inset-x);
52
60
  border-radius: var(--vx-radius-ctrl);
53
61
  transition: transform 140ms cubic-bezier(0.32, 0.72, 0, 1);
54
62
  }
@@ -38,7 +38,7 @@ import classes from './app-mobile-nav.module.css'
38
38
  * bottom-anchored menu puts it UNDER the footer, off-screen — `menuMax` is what guarantees it fits
39
39
  * above the bar (§2.2). `shift.padding` is a viewport inset in px, not a layout spacing token.
40
40
  */
41
- const MENU_MIDDLEWARES = { flip: false, shift: { padding: 8 } } // theme-allow
41
+ const MENU_MIDDLEWARES = { flip: false, shift: { padding: 8 } }
42
42
 
43
43
  /** A slot that navigates on tap. */
44
44
  type LinkSlot = Extract<MobileNavSlot, { kind: 'link' }>
@@ -88,3 +88,55 @@ describe('BasaltShell extraMoreRows', () => {
88
88
  expect(screen.queryByLabelText('More')).toBeNull()
89
89
  })
90
90
  })
91
+
92
+ /**
93
+ * Collapse persistence goes through the HOUSE api, not `@mantine/hooks`.
94
+ *
95
+ * Round 4 filed the reference consumer's raw `localStorage.getItem('basalt-sidebar-collapsed')` as
96
+ * consumer drift; round 5 corrected it — the shell itself used `useLocalStorage`, so the raw read
97
+ * was the only way to mirror what the shell wrote. These tests pin the shape a consumer now reads.
98
+ */
99
+ describe('BasaltShell collapse persistence', () => {
100
+ const COLLAPSE_TOGGLE =
101
+ 'button[aria-label="Collapse sidebar"], button[aria-label="Expand sidebar"]'
102
+
103
+ const renderShell = (storageKey: string) =>
104
+ render(
105
+ <MantineProvider>
106
+ <BasaltShell brand={BRAND} sections={ONE_SECTION} storageKey={storageKey} />
107
+ </MantineProvider>,
108
+ )
109
+
110
+ test('writes the namespaced, versioned envelope — not a bare boolean at a bare key', () => {
111
+ const key = 'collapse-envelope'
112
+ localStorage.clear()
113
+ const { container } = renderShell(key)
114
+
115
+ fireEvent.click(container.querySelector(COLLAPSE_TOGGLE) as HTMLElement)
116
+
117
+ expect(localStorage.getItem(`basalt:${key}`)).toBe(JSON.stringify({ v: 1, value: true }))
118
+ expect(localStorage.getItem(key)).toBeNull()
119
+ })
120
+
121
+ test('adopts a pre-1.20.1 raw value once, so an upgrade keeps the sidebar collapsed', () => {
122
+ const key = 'collapse-legacy'
123
+ localStorage.clear()
124
+ // Exactly what `@mantine/hooks`' useLocalStorage wrote: JSON at the un-namespaced key.
125
+ localStorage.setItem(key, 'true')
126
+
127
+ renderShell(key)
128
+
129
+ expect(localStorage.getItem(`basalt:${key}`)).toBe(JSON.stringify({ v: 1, value: true }))
130
+ })
131
+
132
+ test('a value already in the house key wins over a stale legacy one', () => {
133
+ const key = 'collapse-both'
134
+ localStorage.clear()
135
+ localStorage.setItem(key, 'true')
136
+ localStorage.setItem(`basalt:${key}`, JSON.stringify({ v: 1, value: false }))
137
+
138
+ renderShell(key)
139
+
140
+ expect(localStorage.getItem(`basalt:${key}`)).toBe(JSON.stringify({ v: 1, value: false }))
141
+ })
142
+ })
@@ -14,11 +14,10 @@
14
14
  * sidebar-collapse zustand store) stays consumer-side. The consumer resolves `item.active`,
15
15
  * `item.onClick`, `item.badge`/`item.count` and supplies `item.Anchor` — its router `<Link>`,
16
16
  * which basalt HOSTS rather than delegating rendering to. The breadcrumb is derived from the
17
- * active item across `sections`, not from a router hook. Collapse is persisted via
18
- * `@mantine/hooks` `useLocalStorage` keyed by `storageKey`.
17
+ * active item across `sections`, not from a router hook. Collapse is persisted via basalt's own
18
+ * `createPersistedState` (`../state`) keyed by `storageKey` — see `collapseStore`.
19
19
  */
20
20
  import { AppShell } from '@mantine/core'
21
- import { useLocalStorage } from '@mantine/hooks'
22
21
  import { useMemo } from 'react'
23
22
  import type { MouseEvent, ReactNode } from 'react'
24
23
  import { AppSidebar } from './app-sidebar'
@@ -30,6 +29,7 @@ import type { BasaltAccountProps } from './account-types'
30
29
  import type { SidebarSearchConfig } from './sidebar-search'
31
30
  import type { MobileNavConfig, NavAnchor, SidebarItem, SidebarSection } from '../nav/types'
32
31
  import { useBasaltSpacing } from '../theme'
32
+ import { createPersistedState } from '../state'
33
33
  import headerClasses from './app-header.module.css'
34
34
  import mobileNavClasses from './app-mobile-nav.module.css'
35
35
 
@@ -129,13 +129,20 @@ export type BasaltShellProps = {
129
129
  * `onOpen`, e.g. `() => openSpotlight()` from basalt-ui/commands.
130
130
  */
131
131
  search?: SidebarSearchConfig
132
- /** localStorage key for the persisted sidebar-collapsed flag. Ignored when `collapsed` is set. */
132
+ /**
133
+ * localStorage key for the persisted sidebar-collapsed flag. Ignored when `collapsed` is set.
134
+ *
135
+ * Persisted through basalt's own `createPersistedState`, so the real storage key is
136
+ * `basalt:<storageKey>` and the value is the versioned `{ v, value }` envelope. A consumer
137
+ * mirroring this state reads it with `readPersistedValue(storageKey, 1)` from `basalt-ui/state`
138
+ * — never with a bare `localStorage.getItem`, which is what the pre-1.20.1 shell forced.
139
+ */
133
140
  storageKey?: string
134
141
  /**
135
- * Controlled desktop-collapse value. When provided, the shell no longer owns the
136
- * `useLocalStorage`-persisted collapse state — the consumer does (e.g. to drive it from its own
137
- * `Cmd+B` hotkey). Pair with `onCollapsedChange` to receive toggle events; omitting both
138
- * reproduces today's internal, persisted collapse behavior unchanged.
142
+ * Controlled desktop-collapse value. When provided, the shell no longer owns the persisted
143
+ * collapse state — the consumer does (e.g. to drive it from its own `Cmd+B` hotkey), and should
144
+ * own it through `createPersistedState` too. Pair with `onCollapsedChange` to receive toggle
145
+ * events; omitting both reproduces today's internal, persisted collapse behavior unchanged.
139
146
  */
140
147
  collapsed?: boolean
141
148
  /**
@@ -194,6 +201,54 @@ function findActiveWithParent(
194
201
  return undefined
195
202
  }
196
203
 
204
+ /** Envelope version for the persisted collapse flag. Bump only if the value stops being a boolean. */
205
+ const COLLAPSE_VERSION = 1
206
+
207
+ /**
208
+ * One `createPersistedState` store per `storageKey`, memoized at module scope.
209
+ *
210
+ * Through 1.20.0 the shell persisted collapse with `@mantine/hooks`' `useLocalStorage` while
211
+ * `createPersistedState` was the documented house API — and this component's own docstring told
212
+ * consumers to mirror that when driving `collapsed` externally, so the reference consumer's raw
213
+ * `localStorage` call was COMPLIANCE with the shipped component rather than drift. A framework
214
+ * cannot ship a persistence rule its own shell breaks; this is the shell coming into line.
215
+ *
216
+ * The memo is required, not an optimization: `createPersistedState` is a per-key module FACTORY and
217
+ * `storageKey` is a runtime prop, so calling it during render would allocate a fresh store (and a
218
+ * fresh `useSyncExternalStore` subscription) on every commit. Swapping keys mid-life stays safe —
219
+ * every store's hook calls exactly one `useSyncExternalStore`, so the hook count never moves.
220
+ */
221
+ const collapseStores = new Map<string, () => readonly [boolean, (next: boolean) => void]>()
222
+
223
+ /**
224
+ * Seeds `basalt:<key>` from the raw pre-1.20.1 key once, so the switch doesn't silently re-expand
225
+ * every consumer's sidebar. Mantine's `useLocalStorage` wrote a bare `JSON.stringify(value)` at the
226
+ * un-namespaced key; only `'true'`/`'false'` are accepted, and only when the house key is empty.
227
+ * A one-upgrade bridge, not a supported format — delete it once no consumer predates 1.20.1.
228
+ */
229
+ function migrateLegacyCollapse(key: string): void {
230
+ try {
231
+ if (window.localStorage.getItem(`basalt:${key}`) !== null) return
232
+ const legacy = window.localStorage.getItem(key)
233
+ if (legacy !== 'true' && legacy !== 'false') return
234
+ window.localStorage.setItem(
235
+ `basalt:${key}`,
236
+ JSON.stringify({ v: COLLAPSE_VERSION, value: legacy === 'true' }),
237
+ )
238
+ } catch {
239
+ // Storage blocked (private browsing, quota) — defaulting to expanded is a fine outcome.
240
+ }
241
+ }
242
+
243
+ function collapseStore(key: string): () => readonly [boolean, (next: boolean) => void] {
244
+ const cached = collapseStores.get(key)
245
+ if (cached) return cached
246
+ if (typeof window !== 'undefined') migrateLegacyCollapse(key)
247
+ const store = createPersistedState<boolean>({ key, version: COLLAPSE_VERSION, initial: false })
248
+ collapseStores.set(key, store)
249
+ return store
250
+ }
251
+
197
252
  export function BasaltShell({
198
253
  brand,
199
254
  sections,
@@ -213,11 +268,7 @@ export function BasaltShell({
213
268
  // (controls, the search trigger/avatar, nav labels) already track density, so a fixed literal
214
269
  // container squeezes progressively worse as density rises.
215
270
  const { step } = useBasaltSpacing()
216
- const [storedCollapsed, setStoredCollapsed] = useLocalStorage({
217
- key: storageKey,
218
- defaultValue: false,
219
- getInitialValueInEffect: false,
220
- })
271
+ const [storedCollapsed, setStoredCollapsed] = collapseStore(storageKey)()
221
272
  // Controlled/uncontrolled seam (item 19): an explicit `collapsed` prop overrides the internal
222
273
  // localStorage-persisted state entirely — the consumer becomes the source of truth.
223
274
  const isCollapseControlled = collapsedProp !== undefined
package/src/surfaces.ts CHANGED
@@ -467,7 +467,7 @@ export const SURFACES = {
467
467
  kind: 'tooling',
468
468
  layer: 'non-js-asset',
469
469
  description:
470
- 'Prebuilt --vx-* token stylesheet — the default buildPaletteCss() output as a plain file, for a consumer with no bundler, no React and no Mantine; retune the selector or trim the spacing set with `basalt-ui tokens:css`',
470
+ 'Prebuilt --vx-* token stylesheet — the default buildPaletteCss() output as a plain file, for a consumer that has a bundler to resolve the subpath but no React and no Mantine; with no bundler, emit a file you own via `basalt-ui tokens:css`',
471
471
  forbiddenImports: [],
472
472
  },
473
473
  './configs/*': {