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.
- package/AGENTS.md +9 -5
- package/CHANGELOG.md +19 -0
- package/MIGRATING.md +147 -49
- package/README.md +42 -8
- package/agent/rules/basalt-app.md +41 -0
- package/agent/rules/basalt-charts.md +15 -12
- package/agent/rules/basalt-content.md +4 -0
- package/agent/rules/basalt-router.md +57 -5
- package/agent/rules/basalt-state.md +21 -3
- package/agent/rules/basalt-tokens.md +73 -16
- package/agent/skills/basalt-app/SKILL.md +4 -1
- package/agent/skills/basalt-charts/SKILL.md +5 -3
- package/agent/templates/CLAUDE-block.md.tpl +11 -5
- package/configs/lefthook.yml +48 -15
- package/configs/oxlint-plugin.js +181 -68
- package/configs/oxlint-plugin.test.ts +215 -9
- package/dist/charts/kinds/Bars.d.ts +1 -1
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.d.ts +1 -1
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.d.ts +1 -1
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.d.ts +1 -1
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.d.ts +1 -1
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.d.ts +1 -1
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/cli/index.d.ts +48 -14
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +328 -66
- package/dist/cli/waiver-audit.test.d.ts +2 -0
- package/dist/cli/waiver-audit.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts +47 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +176 -31
- package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
- package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.js +4 -1
- package/dist/router-tanstack/search-param-store.d.ts +29 -5
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-param-store.js +31 -3
- package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
- package/dist/shell/index.d.ts +12 -5
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +24 -6
- package/dist/tokens/index.d.ts +3 -1
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +5 -1
- package/dist/tokens/palette.d.ts +2 -2
- package/dist/tokens/palette.js +2 -2
- package/dist/tokens/text-scale.test.d.ts +2 -0
- package/dist/tokens/text-scale.test.d.ts.map +1 -0
- package/dist/tokens.css +4 -2
- package/dist/vite.d.ts +24 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +54 -13
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/kinds/Bars.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +1 -1
- package/src/charts/kinds/DualPanel.tsx +2 -2
- package/src/charts/kinds/Heatmap.tsx +1 -1
- package/src/charts/kinds/MultiLine.tsx +1 -1
- package/src/charts/kinds/StackedArea.tsx +1 -1
- package/src/charts/kinds/ZonedLine.tsx +1 -1
- package/src/cli/index.ts +607 -128
- package/src/cli/init-sync.test.ts +2 -0
- package/src/cli/template-ledger.test.ts +1 -1
- package/src/cli/toolchain-wiring.test.ts +162 -0
- package/src/cli/waiver-audit.test.ts +131 -0
- package/src/guard/check-source.test.ts +441 -1
- package/src/guard/index.ts +430 -60
- package/src/router-tanstack/multi-search-param-store.ts +16 -1
- package/src/router-tanstack/search-param-store.test.ts +135 -0
- package/src/router-tanstack/search-param-store.ts +90 -8
- package/src/shell/app-mobile-nav.tsx +1 -1
- package/src/shell/index.test.tsx +52 -0
- package/src/shell/index.tsx +64 -13
- package/src/tokens/index.ts +24 -1
- package/src/tokens/palette.ts +2 -2
- package/src/tokens/text-scale.test.ts +51 -0
- package/src/vite.test.ts +125 -6
- 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.
|
|
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:
|
|
71
|
+
* link: linkOptions({ to: '/dashboard', search: dashboardRange.linkSearch }) },
|
|
61
72
|
* { id: 'dash-activity', label: 'Activity', icon: <IconPulse />,
|
|
62
|
-
* link: linkOptions({ to: '/dashboard/activity', search:
|
|
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
|
-
|
|
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]: (
|
|
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 } }
|
|
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' }>
|
package/src/shell/index.test.tsx
CHANGED
|
@@ -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
|
+
})
|
package/src/shell/index.tsx
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
137
|
-
* `
|
|
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] =
|
|
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/tokens/index.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/tokens/palette.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
+
})
|