basalt-ui 1.20.0 → 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 (87) hide show
  1. package/AGENTS.md +9 -5
  2. package/CHANGELOG.md +19 -0
  3. package/MIGRATING.md +147 -49
  4. package/README.md +42 -8
  5. package/agent/rules/basalt-app.md +41 -0
  6. package/agent/rules/basalt-charts.md +15 -12
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-router.md +57 -5
  9. package/agent/rules/basalt-state.md +21 -3
  10. package/agent/rules/basalt-tokens.md +73 -16
  11. package/agent/skills/basalt-app/SKILL.md +4 -1
  12. package/agent/skills/basalt-charts/SKILL.md +5 -3
  13. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  14. package/configs/lefthook.yml +48 -15
  15. package/configs/oxlint-plugin.js +181 -68
  16. package/configs/oxlint-plugin.test.ts +215 -9
  17. package/dist/charts/kinds/Bars.d.ts +1 -1
  18. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  19. package/dist/charts/kinds/Donut.d.ts +1 -1
  20. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  21. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  22. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  23. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  24. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  25. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  26. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  27. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  28. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  29. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  30. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  31. package/dist/cli/index.d.ts +48 -14
  32. package/dist/cli/index.d.ts.map +1 -1
  33. package/dist/cli/index.js +328 -66
  34. package/dist/cli/waiver-audit.test.d.ts +2 -0
  35. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  36. package/dist/guard/index.d.ts +47 -2
  37. package/dist/guard/index.d.ts.map +1 -1
  38. package/dist/guard/index.js +176 -31
  39. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  40. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  41. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  42. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  43. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  44. package/dist/router-tanstack/search-param-store.js +31 -3
  45. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  46. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  47. package/dist/shell/index.d.ts +12 -5
  48. package/dist/shell/index.d.ts.map +1 -1
  49. package/dist/shell/index.js +24 -6
  50. package/dist/tokens/index.d.ts +3 -1
  51. package/dist/tokens/index.d.ts.map +1 -1
  52. package/dist/tokens/index.js +5 -1
  53. package/dist/tokens/palette.d.ts +2 -2
  54. package/dist/tokens/palette.js +2 -2
  55. package/dist/tokens/text-scale.test.d.ts +2 -0
  56. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  57. package/dist/tokens.css +4 -2
  58. package/dist/vite.d.ts +24 -2
  59. package/dist/vite.d.ts.map +1 -1
  60. package/dist/vite.js +54 -13
  61. package/llms.txt +2 -2
  62. package/package.json +1 -1
  63. package/src/charts/kinds/Bars.tsx +1 -1
  64. package/src/charts/kinds/Donut.tsx +1 -1
  65. package/src/charts/kinds/DualPanel.tsx +2 -2
  66. package/src/charts/kinds/Heatmap.tsx +1 -1
  67. package/src/charts/kinds/MultiLine.tsx +1 -1
  68. package/src/charts/kinds/StackedArea.tsx +1 -1
  69. package/src/charts/kinds/ZonedLine.tsx +1 -1
  70. package/src/cli/index.ts +607 -128
  71. package/src/cli/init-sync.test.ts +2 -0
  72. package/src/cli/template-ledger.test.ts +1 -1
  73. package/src/cli/toolchain-wiring.test.ts +162 -0
  74. package/src/cli/waiver-audit.test.ts +131 -0
  75. package/src/guard/check-source.test.ts +441 -1
  76. package/src/guard/index.ts +430 -60
  77. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  78. package/src/router-tanstack/search-param-store.test.ts +135 -0
  79. package/src/router-tanstack/search-param-store.ts +90 -8
  80. package/src/shell/app-mobile-nav.tsx +1 -1
  81. package/src/shell/index.test.tsx +52 -0
  82. package/src/shell/index.tsx +64 -13
  83. package/src/tokens/index.ts +24 -1
  84. package/src/tokens/palette.ts +2 -2
  85. package/src/tokens/text-scale.test.ts +51 -0
  86. package/src/vite.test.ts +125 -6
  87. package/src/vite.ts +148 -20
@@ -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
  }
@@ -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 inline-spacing — a floating-ui viewport inset, not layout spacing
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
@@ -89,8 +89,30 @@ export const alpha = (token: string, a: number): string =>
89
89
  * `theme/index.ts`), so the component surface scales with the user's browser font-size AND with
90
90
  * `--mantine-scale`. `md` is the body step; `lg` is pinned at exactly 16px because it doubles as
91
91
  * the iOS input floor (see the `styles.css` floor — Safari zooms the viewport on focus below 16px).
92
+ *
93
+ * ## Adding a rung
94
+ *
95
+ * The ladder is hand-tuned per ROLE, not generated — but it is not arbitrary either, and the
96
+ * constraint is what keeps a "just add the size I need" request from dissolving it. Every adjacent
97
+ * pair sits between **1.06× and 1.17×**, so a new rung must too:
98
+ *
99
+ * • Below the range, two rungs are visually the same size and the ladder stops being a choice —
100
+ * the guard would then report a size for which two tokens are equally "right".
101
+ * • Above it, the jump reads as a different type system rather than the next step of this one.
102
+ *
103
+ * That test is what admitted `nano` (11/10 = 1.10) and `display` (30/26 = 1.154, the second-widest
104
+ * step, which is the shape a display tier is supposed to have), and what rejected a 20px rung
105
+ * between `xl` (18) and `h2` (21): 21/20 = 1.05 is below the floor, and `h2` is already 1px away
106
+ * from any 20px site. A size the ladder declines to grow a rung for is a size that should snap to
107
+ * its nearest step — not a `theme-allow`.
108
+ *
109
+ * A rung is also a ROLE, not just a number. `nano` and `display` are both deliberately named for
110
+ * where they belong (a label engraved inside a drawn object; a numeral read from across a room)
111
+ * rather than for a position, because the failure mode of a bare `xxs`/`xxl` is that it becomes the
112
+ * escape hatch for every size the ladder was meant to prevent.
92
113
  */
93
114
  const TEXT = {
115
+ nano: 10, // engraved/graphic label INSIDE a drawn object (a plate face, a dial, a chip) — never UI copy or prose
94
116
  micro: 11, // mono uppercase micro-label (table th, Menu.Label, sidebar/section headers, axis ticks)
95
117
  xs: 12.5, // delta badges, tooltip meta, dense chrome, StatCard labels
96
118
  sm: 13.5, // table/stat numerals, chart tooltip, legend
@@ -100,6 +122,7 @@ const TEXT = {
100
122
  h2: 21, // article-density Prose h2 (docs/CONTENT-SPEC.md §5)
101
123
  kpi: 24, // the StatCard hero numeral (density pass: 31 → 24, tighter/less shouty)
102
124
  h1: 26, // article-density Prose h1 (docs/CONTENT-SPEC.md §5)
125
+ display: 30, // a numeral read at arm's length (a keypad readout, a timer, a kiosk figure) — not a heading
103
126
  } as const
104
127
 
105
128
  export const VX = {
@@ -766,7 +789,7 @@ export type BuildPaletteOpts = {
766
789
  * `'core'` keeps the 9 generic anchors — the stack rhythm, the two control heights, the row
767
790
  * insets — and drops the 99 named for a specific basalt component
768
791
  * (`--vx-space-agent-transcript-inset`, `--vx-space-toc-sub-indent`, …), taking the emitted set
769
- * from 202 variables to 103. Nothing outside basalt's own React components reads those, so a
792
+ * from 204 variables to 105. Nothing outside basalt's own React components reads those, so a
770
793
  * framework-free consumer is carrying them for nothing.
771
794
  *
772
795
  * This is a SPACING filter only: color, radius, shadow, type and status variables are identical
@@ -132,7 +132,7 @@ export const SHADOW = {
132
132
  * ever needs more, raise the DROP. Do not reintroduce an edge; both shapes have been tried.
133
133
  */
134
134
  raised: {
135
- light: '0 1px 2px rgba(28, 25, 23, 0.10)',
135
+ light: '0 1px 2px rgba(28, 25, 23, 0.1)',
136
136
  dark: '0 1px 2px rgba(0, 0, 0, 0.35), inset 0 0 0 1px color-mix(in srgb, #ffffff 8%, transparent)',
137
137
  },
138
138
  // Floating-layer elevation (menus, popovers, tooltips, modals, drawers) — a REAL drop shadow, a
@@ -140,7 +140,7 @@ export const SHADOW = {
140
140
  // Unlike `card`, no embedded ring: floating surfaces carry a real 1px `--vx-surface-border` so
141
141
  // Mantine's rotated-square arrow inherits a proper edge.
142
142
  overlay: {
143
- light: '0 8px 24px rgba(28, 25, 23, 0.10)',
143
+ light: '0 8px 24px rgba(28, 25, 23, 0.1)',
144
144
  dark: '0 8px 24px rgba(0, 0, 0, 0.5)',
145
145
  },
146
146
  } as const
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The type ladder's own law — the thing that decides whether a requested size gets a rung.
3
+ *
4
+ * This exists because the ladder is hand-tuned rather than generated, which is exactly the shape
5
+ * that erodes: a consumer reports "no step for 20px", someone adds 20, and now 20 and 21 are the
6
+ * same size and the guard reports a violation with two equally-correct remedies. Pinning the ratio
7
+ * band makes the next request answerable with arithmetic instead of taste.
8
+ */
9
+ import { describe, expect, it } from 'bun:test'
10
+ import { VX } from './index'
11
+
12
+ /** Every adjacent pair of the shipped ladder, ascending. */
13
+ const STEPS = Object.values(VX.text)
14
+ const RATIOS = STEPS.slice(1).map((size, index) => size / STEPS[index]!)
15
+
16
+ /** The band documented on `TEXT` in `tokens/index.ts`. Widen it only with a reason written there. */
17
+ const MIN_RATIO = 1.06
18
+ const MAX_RATIO = 1.17
19
+
20
+ describe('the type ladder', () => {
21
+ it('is strictly ascending, so `Object.values` order IS the ladder order', () => {
22
+ expect(STEPS).toEqual(STEPS.toSorted((a, b) => a - b))
23
+ expect(new Set(STEPS).size).toBe(STEPS.length)
24
+ })
25
+
26
+ it('keeps every adjacent step inside the 1.06–1.17 band', () => {
27
+ const offenders = RATIOS.map((ratio, index) => ({
28
+ pair: `${STEPS[index]} → ${STEPS[index + 1]}`,
29
+ ratio: Number(ratio.toFixed(3)),
30
+ })).filter(({ ratio }) => ratio < MIN_RATIO || ratio > MAX_RATIO)
31
+
32
+ expect(offenders).toEqual([])
33
+ })
34
+
35
+ it('spans the two ends consumers reported as unreachable', () => {
36
+ // A label engraved inside a drawn object, and a numeral read at arm's length. Both arrived from
37
+ // the same consumer round; both are roles the ladder genuinely had no step for.
38
+ expect(VX.text.nano).toBe(10)
39
+ expect(VX.text.display).toBe(30)
40
+ })
41
+
42
+ it('has no rung between xl and h2 — 21/20 = 1.05 is below the band', () => {
43
+ // The third site from that report. `h2` is the remedy, not a waiver: it is 1px away.
44
+ expect(STEPS.filter((size) => size > VX.text.xl && size < VX.text.h2)).toEqual([])
45
+ expect(VX.text.h2 / 20).toBeLessThan(MIN_RATIO)
46
+ })
47
+
48
+ it('pins the iOS input floor, which is a rung with an external constraint', () => {
49
+ expect(VX.text.lg).toBe(16)
50
+ })
51
+ })