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.
- package/AGENTS.md +19 -1
- package/CHANGELOG.md +39 -0
- package/MIGRATING.md +326 -0
- package/README.md +87 -9
- package/agent/rules/basalt-app.md +84 -0
- package/agent/rules/basalt-charts.md +32 -17
- package/agent/rules/basalt-content.md +4 -0
- package/agent/rules/basalt-mantine.md +25 -10
- package/agent/rules/basalt-router.md +79 -15
- package/agent/rules/basalt-state.md +21 -3
- package/agent/rules/basalt-tokens.md +132 -6
- package/agent/skills/basalt-app/SKILL.md +45 -6
- package/agent/skills/basalt-charts/SKILL.md +5 -3
- package/agent/templates/CLAUDE-block.md.tpl +11 -5
- package/configs/check.yml +11 -4
- package/configs/lefthook.yml +59 -7
- package/configs/oxlint-plugin.js +633 -30
- package/configs/oxlint-plugin.test.ts +594 -15
- package/configs/oxlint.json +10 -1
- package/dist/agent/stick-to-bottom.js +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-message.js +2 -2
- package/dist/agent-chat/tool-chip.js +1 -1
- 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/Donut.js +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/charts/primitives/ChartLegend.js +2 -2
- package/dist/charts/primitives/ChartTooltip.js +1 -1
- package/dist/cli/index.d.ts +146 -20
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +901 -101
- package/dist/cli/toolchain-wiring.test.d.ts +2 -0
- package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
- package/dist/cli/waiver-audit.test.d.ts +2 -0
- package/dist/cli/waiver-audit.test.d.ts.map +1 -0
- package/dist/dashboard/delta-badge.js +2 -2
- package/dist/dashboard/stat-card.js +1 -1
- package/dist/data/virtual-list.js +5 -5
- package/dist/guard/index.d.ts +101 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +474 -62
- package/dist/guard/types.d.ts +25 -5
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/register.d.ts +25 -0
- package/dist/register.d.ts.map +1 -1
- 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/app-mobile-nav.module.css +11 -3
- 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/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/dist/tokens/index.d.ts +5 -3
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +7 -1
- package/dist/tokens/palette.d.ts +35 -27
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +24 -18
- 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 +21 -17
- package/dist/vite-color-mix.js +2 -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 +3 -3
- package/package.json +4 -3
- package/src/agent/stick-to-bottom.tsx +1 -1
- package/src/agent-chat/thread-detail-panel.tsx +1 -1
- package/src/agent-chat/thread-message.tsx +3 -3
- package/src/agent-chat/tool-chip.tsx +1 -1
- package/src/charts/kinds/Bars.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +3 -2
- 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/charts/primitives/ChartLegend.tsx +2 -2
- package/src/charts/primitives/ChartTooltip.tsx +1 -1
- package/src/cli/index.ts +1614 -164
- package/src/cli/init-sync.test.ts +21 -3
- package/src/cli/template-ledger.test.ts +1 -1
- package/src/cli/tokens-css.test.ts +27 -14
- package/src/cli/toolchain-wiring.test.ts +856 -0
- package/src/cli/waiver-audit.test.ts +131 -0
- package/src/dashboard/delta-badge.tsx +3 -3
- package/src/dashboard/stat-card.tsx +1 -1
- package/src/data/virtual-list.tsx +8 -8
- package/src/guard/check-source.test.ts +1104 -12
- package/src/guard/index.ts +1106 -103
- package/src/guard/types.ts +29 -4
- package/src/register.ts +32 -0
- 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.module.css +11 -3
- 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/surfaces.ts +1 -1
- package/src/theme/density-relations.test.ts +8 -5
- package/src/theme/spacing.test.ts +2 -0
- package/src/theme-toggle/index.tsx +2 -2
- package/src/tokens/index.ts +28 -3
- package/src/tokens/palette.ts +24 -18
- package/src/tokens/text-scale.test.ts +51 -0
- package/src/vite-color-mix.ts +2 -2
- package/src/vite.test.ts +125 -6
- package/src/vite.ts +148 -20
package/src/guard/types.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
155
|
-
*
|
|
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.
|
|
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
|
}
|
|
@@ -46,9 +46,17 @@
|
|
|
46
46
|
display: inline-flex;
|
|
47
47
|
align-items: center;
|
|
48
48
|
justify-content: center;
|
|
49
|
-
/*
|
|
50
|
-
*
|
|
51
|
-
|
|
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 } }
|
|
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/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
|
|
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/*': {
|