basalt-ui 1.26.0 → 1.27.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 (107) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +8 -0
  3. package/MIGRATING.md +299 -33
  4. package/agent/rules/basalt-controls.md +17 -12
  5. package/agent/rules/basalt-state.md +5 -0
  6. package/configs/oxlint-plugin.js +251 -66
  7. package/configs/oxlint-plugin.test.ts +243 -0
  8. package/configs/oxlint.json +5 -5
  9. package/dist/controls/enum-filter.d.ts +11 -2
  10. package/dist/controls/enum-filter.d.ts.map +1 -1
  11. package/dist/controls/enum-filter.js +4 -2
  12. package/dist/controls/filter-context.d.ts +7 -2
  13. package/dist/controls/filter-context.d.ts.map +1 -1
  14. package/dist/controls/index.d.ts +1 -0
  15. package/dist/controls/index.d.ts.map +1 -1
  16. package/dist/controls/index.js +2 -0
  17. package/dist/controls/multi-select-filter.js +2 -2
  18. package/dist/controls/number-filter.d.ts +26 -0
  19. package/dist/controls/number-filter.d.ts.map +1 -0
  20. package/dist/controls/number-filter.js +143 -0
  21. package/dist/controls/range-filter.d.ts +19 -5
  22. package/dist/controls/range-filter.d.ts.map +1 -1
  23. package/dist/controls/range-filter.js +22 -7
  24. package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
  25. package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
  26. package/dist/controls/search-filter.js +1 -1
  27. package/dist/controls/toggle-filter.js +1 -1
  28. package/dist/dashboard/index.d.ts +1 -1
  29. package/dist/dashboard/index.d.ts.map +1 -1
  30. package/dist/dashboard/index.js +3 -1
  31. package/dist/dashboard/stat-card.d.ts +52 -1
  32. package/dist/dashboard/stat-card.d.ts.map +1 -1
  33. package/dist/dashboard/stat-card.js +42 -15
  34. package/dist/dashboard/stat-card.module.css +71 -0
  35. package/dist/dashboard/stat-card.module.css.d.ts +4 -0
  36. package/dist/data/data-table.d.ts.map +1 -1
  37. package/dist/data/data-table.js +8 -0
  38. package/dist/guard/grace.test.d.ts.map +1 -1
  39. package/dist/guard/index.d.ts +1 -1
  40. package/dist/guard/index.d.ts.map +1 -1
  41. package/dist/guard/index.js +6 -10
  42. package/dist/index.d.ts +2 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/router-tanstack/field.d.ts +1 -1
  45. package/dist/router-tanstack/field.d.ts.map +1 -1
  46. package/dist/router-tanstack/index.d.ts +1 -1
  47. package/dist/router-tanstack/index.d.ts.map +1 -1
  48. package/dist/router-tanstack/search-store.d.ts +4 -1
  49. package/dist/router-tanstack/search-store.d.ts.map +1 -1
  50. package/dist/router-tanstack/search-store.js +91 -7
  51. package/dist/shell/page-bar.d.ts +1 -1
  52. package/dist/state/fields.d.ts +209 -36
  53. package/dist/state/fields.d.ts.map +1 -1
  54. package/dist/state/fields.js +68 -32
  55. package/dist/state/persisted.d.ts +12 -0
  56. package/dist/state/persisted.d.ts.map +1 -1
  57. package/dist/state/persisted.js +36 -18
  58. package/dist/state/persisted.test.d.ts +2 -0
  59. package/dist/state/persisted.test.d.ts.map +1 -0
  60. package/dist/state.d.ts +1 -1
  61. package/dist/state.d.ts.map +1 -1
  62. package/dist/surfaces.d.ts +1 -1
  63. package/dist/surfaces.d.ts.map +1 -1
  64. package/dist/surfaces.js +4 -3
  65. package/dist/widget-header/widget-header.d.ts +31 -1
  66. package/dist/widget-header/widget-header.d.ts.map +1 -1
  67. package/dist/widget-header/widget-header.js +13 -1
  68. package/dist/widget-header/widget-header.module.css +15 -0
  69. package/dist/widget-header/widget-header.module.css.d.ts +1 -0
  70. package/llms.txt +3 -3
  71. package/package.json +1 -1
  72. package/src/controls/controls.router.test.tsx +416 -0
  73. package/src/controls/enum-filter.tsx +14 -3
  74. package/src/controls/filter-context.tsx +7 -2
  75. package/src/controls/index.ts +1 -0
  76. package/src/controls/multi-select-filter.tsx +2 -2
  77. package/src/controls/number-filter.tsx +291 -0
  78. package/src/controls/props-contract.test.tsx +31 -0
  79. package/src/controls/range-filter.tsx +64 -11
  80. package/src/controls/range-filter.type-guard.test.tsx +122 -0
  81. package/src/controls/search-filter.tsx +1 -1
  82. package/src/controls/toggle-filter.tsx +1 -1
  83. package/src/dashboard/index.ts +6 -1
  84. package/src/dashboard/stat-card.module.css +71 -0
  85. package/src/dashboard/stat-card.module.css.d.ts +4 -0
  86. package/src/dashboard/stat-card.test.tsx +231 -0
  87. package/src/dashboard/stat-card.tsx +81 -0
  88. package/src/data/data-table.tsx +8 -0
  89. package/src/guard/check-source.test.ts +35 -2
  90. package/src/guard/grace.test.ts +14 -5
  91. package/src/guard/index.ts +30 -17
  92. package/src/index.ts +5 -0
  93. package/src/router-tanstack/field.ts +4 -0
  94. package/src/router-tanstack/index.ts +4 -0
  95. package/src/router-tanstack/search-store.router.test.tsx +232 -11
  96. package/src/router-tanstack/search-store.test.ts +160 -0
  97. package/src/router-tanstack/search-store.ts +177 -11
  98. package/src/shell/page-bar.tsx +1 -1
  99. package/src/state/fields.test.tsx +254 -0
  100. package/src/state/fields.ts +328 -70
  101. package/src/state/persisted.test.ts +91 -0
  102. package/src/state/persisted.ts +76 -26
  103. package/src/state.ts +6 -0
  104. package/src/surfaces.ts +4 -3
  105. package/src/widget-header/widget-header.module.css +15 -0
  106. package/src/widget-header/widget-header.module.css.d.ts +1 -0
  107. package/src/widget-header/widget-header.tsx +46 -1
@@ -31,7 +31,15 @@
31
31
  */
32
32
  import { useNavigate, useSearch } from '@tanstack/react-router'
33
33
  import { createStoreCore } from '../state'
34
- import type { AnyField, FieldHandle, SearchValues, StoreEntry, StoredValues } from '../state'
34
+ import type {
35
+ AnyField,
36
+ FieldHandle,
37
+ FieldSetOptions,
38
+ FieldWrite,
39
+ SearchValues,
40
+ StoreEntry,
41
+ StoredValues,
42
+ } from '../state'
35
43
 
36
44
  // ── Types ──────────────────────────────────────────────────────────────────
37
45
 
@@ -67,7 +75,10 @@ export type SearchStore<S extends Record<string, AnyField>> = {
67
75
  useValues: () => SearchValues<S>
68
76
  /** How many fields differ from their fallback — the `n` in a `Filters (n)` pill. */
69
77
  useActiveCount: () => number
70
- /** Resets every field to its fallback: one navigate, one storage write. */
78
+ /**
79
+ * Returns every field to its fallback: one navigate, one storage write. The mirror is UNSET
80
+ * rather than written — see `FieldHandle.clear()` for why writing the fallback pins a thunk.
81
+ */
71
82
  useReset: () => () => void
72
83
  }
73
84
 
@@ -129,6 +140,48 @@ function warnWriteHasNowhereToGo(input: { key: string; name: string; param: stri
129
140
  )
130
141
  }
131
142
 
143
+ /**
144
+ * Dev-only, once per param: a `patch` named a search param ANOTHER field of the same store owns.
145
+ * The navigate would put it in the URL while the store's own write path never touched the mirror,
146
+ * so the next paramless visit and every `linkSearch` link resolve the OLD value — the URL and the
147
+ * mirror disagree from that click on, and nothing looks broken until a reader shares a link.
148
+ *
149
+ * Thrown in dev rather than warned: the call is a mistake with a one-line fix (write the other
150
+ * field through its own setter), and the damage is silent. Production keeps the write — a thrown
151
+ * error there would take a page down over a stale param — and logs it once.
152
+ */
153
+ function patchOwnsFieldMessage(input: {
154
+ key: string
155
+ name: string
156
+ owner: string
157
+ param: string
158
+ }): string {
159
+ const { key, name, owner, param } = input
160
+ return (
161
+ `basalt-ui: createSearchStore('${key}'): set('${name}', …, { patch }) carries \`${param}\`, ` +
162
+ `which this store's field '${owner}' owns — the URL would take it while the localStorage ` +
163
+ 'mirror kept the old value, so the next paramless visit and every `linkSearch` link disagree ' +
164
+ `with the page the write happened on. Write it through \`field.${owner}.use()[1]\` instead; ` +
165
+ 'a `patch` is for keys the store does NOT own.'
166
+ )
167
+ }
168
+
169
+ /**
170
+ * Dev-only, once per field: a patched write from a route that does not validate the field. The
171
+ * write persists (A1), but the patch has no navigate to merge into and is dropped — the one case
172
+ * where half of a two-part write silently disappears.
173
+ */
174
+ function warnPatchDropped(input: { key: string; name: string; param: string }): void {
175
+ const { key, name, param } = input
176
+ // oxlint-disable-next-line no-console -- a dev-time wiring warning has no other channel
177
+ console.warn(
178
+ `[basalt-ui] createSearchStore('${key}'): wrote field '${name}' with a \`patch\` from a route ` +
179
+ `that does not validate \`${param}\` — the field's value persisted (A1), but the patch was ` +
180
+ 'DROPPED: there is no navigate to merge it into. Render the control on the route wired to ' +
181
+ '`validateSearch`, or apply that sibling param on the route that owns it. (dev only)',
182
+ )
183
+ }
184
+
132
185
  /**
133
186
  * The public store plus the one member only the deprecated wrappers need: `markReaderWired`, so a
134
187
  * consumer whose only reader is the wrapper's own `useStore()` still counts as wired and never sees
@@ -140,6 +193,25 @@ export type InternalSearchStore<S extends Record<string, AnyField>> = SearchStor
140
193
  markReaderWired: () => void
141
194
  }
142
195
 
196
+ /**
197
+ * A thunk fallback belongs to the LOCAL and MEMORY lanes only. On the URL lane `validateSearch`
198
+ * would evaluate it on every navigation and write the result into the URL, so a deep link would
199
+ * carry a value nobody picked and two visits a second apart would disagree. Thrown at definition
200
+ * rather than warned at read time: the field set is static, so there is no run in which this is
201
+ * anything but a mistake.
202
+ */
203
+ function assertNoLazyUrlFallback(key: string, fields: Record<string, AnyField>): void {
204
+ for (const [name, f] of Object.entries(fields)) {
205
+ if (typeof f.fallback !== 'function' || !f.lane.url) continue
206
+ throw new Error(
207
+ `basalt-ui: createSearchStore('${key}'): field '${name}' has a thunk fallback on the URL ` +
208
+ 'lane — `validateSearch` would evaluate it on every navigation and pin the result into the ' +
209
+ 'URL. Pass a value instead, or move the field off the URL lane (`{ url: false }`, or ' +
210
+ '`createLocalStore`), where a lazy fallback is resolved per read.',
211
+ )
212
+ }
213
+ }
214
+
143
215
  /** The memory-only lane: neither the URL nor the mirror, so the value lives in the store's own
144
216
  * session-scoped external store (`StoreCore.memoryUse`) — the lane `createLocalStore` also uses. */
145
217
  function isMemoryLane(entry: StoreEntry): boolean {
@@ -154,6 +226,7 @@ function isMemoryLane(entry: StoreEntry): boolean {
154
226
  export function buildSearchStore<const S extends Record<string, AnyField>>(
155
227
  o: CreateSearchStoreOptions<S> & { legacyValueField?: string },
156
228
  ): InternalSearchStore<S> {
229
+ assertNoLazyUrlFallback(o.key, o.fields)
157
230
  const core = createStoreCore(o)
158
231
 
159
232
  // `validateSearch` reads storage on every navigation, which says nothing about whether the
@@ -167,6 +240,39 @@ export function buildSearchStore<const S extends Record<string, AnyField>>(
167
240
  readerWired = true
168
241
  }
169
242
  const nowhereWarned = new Set<string>()
243
+ const patchDroppedWarned = new Set<string>()
244
+ const patchOwnerLogged = new Set<string>()
245
+
246
+ /** Every URL param this store owns, mapped to the field that owns it — what a `patch` may not name. */
247
+ const paramOwners = new Map<string, string>()
248
+ for (const entry of core.urlEntries) {
249
+ for (const param of entry.codec.params) paramOwners.set(param, entry.name)
250
+ }
251
+ const persistedNames = new Set(
252
+ core.entries.filter((entry) => entry.codec.lane.persist).map((entry) => entry.name),
253
+ )
254
+
255
+ /**
256
+ * The URL lane's `clear()` needs a `navigate`, which only exists inside a render. Each render of
257
+ * `urlUse` therefore leaves the navigate its mount would use here, keyed by field — so `clear()`
258
+ * from an event handler on that mount navigates exactly as its setter would, and a field no mount
259
+ * has read falls back to clearing the mirror alone.
260
+ */
261
+ const urlClear = new Map<string, () => void>()
262
+
263
+ const assertPatchNotOwned = (entry: StoreEntry, patch: Record<string, unknown>): void => {
264
+ for (const param of Object.keys(patch)) {
265
+ const owner = paramOwners.get(param)
266
+ // The field's OWN params are harmless: its `toSearch` is spread after the patch and wins.
267
+ if (owner === undefined || entry.codec.params.includes(param)) continue
268
+ const message = patchOwnsFieldMessage({ key: o.key, name: entry.name, owner, param })
269
+ if (process.env['NODE_ENV'] !== 'production') throw new Error(message)
270
+ if (patchOwnerLogged.has(param)) continue
271
+ patchOwnerLogged.add(param)
272
+ // oxlint-disable-next-line no-console -- production keeps the write; the log is the record
273
+ console.error(message)
274
+ }
275
+ }
170
276
 
171
277
  const flatten = (
172
278
  search: Record<string, unknown> | null,
@@ -215,7 +321,7 @@ export function buildSearchStore<const S extends Record<string, AnyField>>(
215
321
  return core.readStoredFlat() as Partial<StoredValues<S>>
216
322
  }
217
323
 
218
- const urlUse = (entry: StoreEntry) => (): readonly [unknown, (next: unknown) => void] => {
324
+ const urlUse = (entry: StoreEntry) => (): readonly [unknown, FieldWrite] => {
219
325
  const search = useCurrentSearch()
220
326
  const navigate = useNavigate()
221
327
  const [record, setRecord] = core.usePersistedRecord()
@@ -224,14 +330,45 @@ export function buildSearchStore<const S extends Record<string, AnyField>>(
224
330
  // The matched route validates this field iff its primary param is present (see the module
225
331
  // header). A foreign route gets the persist-only lane.
226
332
  const validated = Object.hasOwn(search, entry.codec.primary)
333
+ urlClear.set(entry.name, () => {
334
+ if (!validated) return
335
+ navigate({
336
+ to: '.',
337
+ search: (prev: Record<string, unknown>) => ({
338
+ ...prev,
339
+ ...entry.codec.toSearch(entry.codec.fallback),
340
+ }),
341
+ replace: true,
342
+ resetScroll: false,
343
+ })
344
+ })
227
345
 
228
346
  return [
229
347
  value,
230
- (next: unknown) => {
348
+ (next: unknown, opts?: FieldSetOptions) => {
349
+ const patch = opts?.patch
350
+ if (patch !== undefined && Object.keys(patch).length > 0) {
351
+ assertPatchNotOwned(entry, patch)
352
+ if (
353
+ !validated &&
354
+ !patchDroppedWarned.has(entry.name) &&
355
+ process.env['NODE_ENV'] !== 'production'
356
+ ) {
357
+ patchDroppedWarned.add(entry.name)
358
+ warnPatchDropped({ key: o.key, name: entry.name, param: entry.codec.primary })
359
+ }
360
+ }
231
361
  if (validated) {
232
362
  navigate({
233
363
  to: '.',
234
- search: (prev: Record<string, unknown>) => ({ ...prev, ...entry.codec.toSearch(next) }),
364
+ // `patch` FIRST, the field's own params last: a patch is for keys the store does not
365
+ // own (clearing a sibling `detailDate` with `undefined`), so it must never be able to
366
+ // overwrite the value this very call is setting.
367
+ search: (prev: Record<string, unknown>) => ({
368
+ ...prev,
369
+ ...opts?.patch,
370
+ ...entry.codec.toSearch(next),
371
+ }),
235
372
  replace: entry.codec.lane.history === 'replace',
236
373
  // A filter lives halfway down a page as often as it lives in the bar, and the router's
237
374
  // default scroll restoration treats this same-route search write as a navigation — so
@@ -252,7 +389,9 @@ export function buildSearchStore<const S extends Record<string, AnyField>>(
252
389
  ] as const
253
390
  }
254
391
 
255
- const localUse = (entry: StoreEntry) => (): readonly [unknown, (next: unknown) => void] => {
392
+ // No `patch` here or in the memory lane: neither navigates, so there is no search object to merge
393
+ // into and nothing to silently half-apply.
394
+ const localUse = (entry: StoreEntry) => (): readonly [unknown, FieldWrite] => {
256
395
  const [record, setRecord] = core.usePersistedRecord()
257
396
  markReaderWired()
258
397
  return [
@@ -265,6 +404,19 @@ export function buildSearchStore<const S extends Record<string, AnyField>>(
265
404
  // memory-only. The third used to be the one combination that dropped its write.
266
405
  const memoryEntries = core.entries.filter(isMemoryLane)
267
406
 
407
+ /**
408
+ * Unsetting one field, per lane: drop the mirror key (never write the fallback — a thunk fallback
409
+ * resolved into localStorage is a value nobody chose), drop the memory value, and on the URL lane
410
+ * navigate back to the fallback params through the last render of `use()`.
411
+ */
412
+ const clearFor = (entry: StoreEntry) => (): void => {
413
+ if (entry.codec.lane.persist) core.clearField(entry.name)
414
+ else if (!entry.codec.lane.url) core.clearMemory(entry.name)
415
+ if (!entry.codec.lane.url) return
416
+ const clearUrl = urlClear.get(entry.name)
417
+ if (clearUrl !== undefined) clearUrl()
418
+ }
419
+
268
420
  const field = {} as { [K in keyof S]: FieldHandle<S[K]> }
269
421
  for (const entry of core.entries) {
270
422
  const use = entry.codec.lane.url
@@ -272,7 +424,11 @@ export function buildSearchStore<const S extends Record<string, AnyField>>(
272
424
  : entry.codec.lane.persist
273
425
  ? localUse(entry)
274
426
  : core.memoryUse(entry)
275
- field[entry.name as keyof S] = core.handle(entry, use) as unknown as FieldHandle<S[keyof S]>
427
+ field[entry.name as keyof S] = core.handle(
428
+ entry,
429
+ use,
430
+ clearFor(entry),
431
+ ) as unknown as FieldHandle<S[keyof S]>
276
432
  }
277
433
 
278
434
  const useValues = (): SearchValues<S> => {
@@ -325,11 +481,21 @@ export function buildSearchStore<const S extends Record<string, AnyField>>(
325
481
  }
326
482
  if (memoryEntries.length > 0) core.resetMemory()
327
483
  if (!core.anyPersisted) return
328
- const next = { ...core.readRecord() }
329
- for (const entry of core.entries) {
330
- if (entry.codec.lane.persist) next[entry.name] = entry.codec.fallback
484
+ // Reset means UNSET, not "write the fallback": `codec.fallback` is the RESOLVED value, so a
485
+ // thunk (`() => todayIso()`) would be pinned into the mirror by the very act of resetting and
486
+ // read back tomorrow as a choice nobody made. Deleting the key lets `resolve` fall through to
487
+ // the live fallback — which is what `resetMemory` above has always done for the memory lane.
488
+ const record = core.readRecord()
489
+ const next: Record<string, unknown> = {}
490
+ let dropped = false
491
+ for (const [name, value] of Object.entries(record)) {
492
+ if (persistedNames.has(name)) {
493
+ dropped = true
494
+ continue
495
+ }
496
+ next[name] = value
331
497
  }
332
- setRecord(next)
498
+ if (dropped) setRecord(next)
333
499
  }
334
500
  }
335
501
 
@@ -137,7 +137,7 @@ export type PageBarProps = {
137
137
  * sticky wrapper (`data-basalt-page-bar="shell"`) inside a `BasaltShell`. The seam for the two
138
138
  * things only the consumer's own layout knows: bleeding the sticky bar across its container's
139
139
  * gutters (`margin-inline: calc(var(--gutter) * -1); padding-inline: var(--gutter)`) and drawing
140
- * a hairline under it (`border-bottom: 1px solid var(--vx-border)`).
140
+ * a hairline under it (`border-bottom: 1px solid var(--vx-surface-hairline)`).
141
141
  *
142
142
  * Scope that CSS through this class, not through a global `[data-basalt-page-bar]` selector: the
143
143
  * data attributes are stable enough to READ (a shell-less consumer can style
@@ -256,3 +256,257 @@ describe('createLocalStore — labels()', () => {
256
256
  ])
257
257
  })
258
258
  })
259
+
260
+ /**
261
+ * A lazy fallback — `fallback: () => T`. The local store is its home: `createSearchStore` allows it
262
+ * off the URL lane only (a thunk in `validateSearch` would pin a computed value into a deep link),
263
+ * and every lane here is off the URL by construction.
264
+ */
265
+ describe('createLocalStore — a lazy fallback', () => {
266
+ test('is not called at definition, resolves at read, and is re-read while nothing is written', () => {
267
+ let calls = 0
268
+ let today = '2026-01-01'
269
+ const store = createLocalStore({
270
+ key: 'l-lazy',
271
+ fields: {
272
+ since: field.string({
273
+ fallback: () => {
274
+ calls += 1
275
+ return today
276
+ },
277
+ }),
278
+ },
279
+ })
280
+ // Definition must not run it: a store is built at module scope, where "now" is meaningless.
281
+ expect(calls).toBe(0)
282
+
283
+ const { result } = renderHook(() => store.field.since.use())
284
+ expect(result.current[0]).toBe('2026-01-01')
285
+ expect(calls).toBeGreaterThan(0)
286
+
287
+ // The thunk is the value while the field is unset — a later read sees what it returns NOW.
288
+ today = '2026-02-01'
289
+ expect(store.field.since.fallback).toBe('2026-02-01')
290
+ expect(store.field.since.isDefault('2026-02-01')).toBe(true)
291
+ expect(store.field.since.isDefault('2026-01-01')).toBe(false)
292
+
293
+ // Nothing was persisted by reading — a fallback is not a value until a write makes it one.
294
+ expect(localStorage.getItem('basalt:l-lazy')).toBeNull()
295
+ })
296
+
297
+ test('a write wins over the thunk, and the thunk stops being consulted', () => {
298
+ let calls = 0
299
+ const store = createLocalStore({
300
+ key: 'l-lazy-write',
301
+ fields: {
302
+ rows: field.number({
303
+ fallback: () => {
304
+ calls += 1
305
+ return 10
306
+ },
307
+ min: 1,
308
+ }),
309
+ },
310
+ })
311
+
312
+ const { result } = renderHook(() => store.field.rows.use())
313
+ expect(result.current[0]).toBe(10)
314
+ act(() => {
315
+ result.current[1](25)
316
+ })
317
+
318
+ const after = calls
319
+ expect(result.current[0]).toBe(25)
320
+ expect(storedRecord('l-lazy-write')).toEqual({ rows: 25 })
321
+ expect(calls).toBe(after)
322
+ })
323
+
324
+ test('the memory lane resolves it too — every kind takes the thunk form', () => {
325
+ let preset = '7d'
326
+ let n = 1
327
+ const store = createLocalStore({
328
+ key: 'l-lazy-memory',
329
+ fields: {
330
+ metric: field.enum(['load', 'volume'], () => 'volume', { persist: false }),
331
+ range: field.range({ presets: ['7d', '30d'], fallback: () => preset as '7d' | '30d' }),
332
+ tags: field.multi(['api', 'design'], () => ['design']),
333
+ compact: field.boolean(() => true),
334
+ // The two kinds this test used to omit. `number` builds its codec with an EXTRA member
335
+ // (`bounds`), which is exactly how it once ended up spreading the shell and freezing this
336
+ // fallback at definition — the whole kind list is here so that cannot recur unnoticed.
337
+ count: field.number({ fallback: () => n, min: 0 }),
338
+ label: field.string({ fallback: () => String(n) }),
339
+ },
340
+ })
341
+
342
+ const metric = renderHook(() => store.field.metric.use())
343
+ expect(metric.result.current[0]).toBe('volume')
344
+ expect(store.field.range.fallback).toEqual({ preset: '7d' })
345
+ preset = '30d'
346
+ expect(store.field.range.fallback).toEqual({ preset: '30d' })
347
+ expect(store.field.tags.fallback).toEqual(['design'])
348
+ expect(store.field.compact.fallback).toBe(true)
349
+ expect([store.field.count.fallback, store.field.label.fallback]).toEqual([1, '1'])
350
+
351
+ // Read at READ time, not at definition: a later read sees what the thunk returns now.
352
+ n = 2
353
+ expect([store.field.count.fallback, store.field.label.fallback]).toEqual([2, '2'])
354
+ const count = renderHook(() => store.field.count.use())
355
+ expect(count.result.current[0]).toBe(2)
356
+ })
357
+
358
+ test('clear() unsets the mirror, so the thunk resolves again', () => {
359
+ let today = '2026-08-27'
360
+ const store = createLocalStore({
361
+ key: 'l-lazy-clear',
362
+ fields: {
363
+ since: field.string({ fallback: () => today }),
364
+ metric: field.enum(['load', 'volume'], 'load'),
365
+ },
366
+ })
367
+ persist('l-lazy-clear', { since: '2026-01-01', metric: 'volume' })
368
+
369
+ const { result } = renderHook(() => store.field.since.use())
370
+ expect(result.current[0]).toBe('2026-01-01')
371
+
372
+ act(() => {
373
+ store.field.since.clear()
374
+ })
375
+
376
+ // The KEY is gone — writing the resolved fallback instead would hand a reader '2026-08-27'
377
+ // tomorrow, as a value they never chose. The sibling field is untouched.
378
+ expect(storedRecord('l-lazy-clear')).toEqual({ metric: 'volume' })
379
+ expect(result.current[0]).toBe('2026-08-27')
380
+ today = '2026-08-28'
381
+ expect(store.field.since.fallback).toBe('2026-08-28')
382
+ expect(store.field.since.isDefault('2026-08-28')).toBe(true)
383
+ })
384
+
385
+ test('clear() on the memory lane drops that field only', () => {
386
+ const store = createLocalStore({
387
+ key: 'l-clear-memory',
388
+ fields: {
389
+ scratch: field.enum(['a', 'b'], 'a', { persist: false }),
390
+ other: field.enum(['a', 'b'], 'a', { persist: false }),
391
+ },
392
+ })
393
+
394
+ const scratch = renderHook(() => store.field.scratch.use())
395
+ const other = renderHook(() => store.field.other.use())
396
+ act(() => {
397
+ scratch.result.current[1]('b')
398
+ other.result.current[1]('b')
399
+ })
400
+ expect([scratch.result.current[0], other.result.current[0]]).toEqual(['b', 'b'])
401
+
402
+ act(() => {
403
+ store.field.scratch.clear()
404
+ })
405
+ expect([scratch.result.current[0], other.result.current[0]]).toEqual(['a', 'b'])
406
+ expect(localStorage.getItem('basalt:l-clear-memory')).toBeNull()
407
+ })
408
+
409
+ /**
410
+ * `toWindow`'s TYPE, not just its runtime: the presets that declared a resolver are excluded from
411
+ * the `{ window }` branch, so the result assigns to an API param type naming only the
412
+ * server-understood windows. Before that, a consumer replacing its `presetToParams` switch with
413
+ * `toWindow` needed a cast — the workaround moved rather than going away.
414
+ */
415
+ test('toWindow drops the resolved presets from its return TYPE', () => {
416
+ const store = createLocalStore({
417
+ key: 'l-towindow',
418
+ fields: {
419
+ window: field.range({
420
+ presets: ['7d', '30d', '90d', '3m', 'ytd', 'all'],
421
+ fallback: '30d',
422
+ window: {
423
+ '3m': () => ({ from: '2026-06-01', to: '2026-08-27' }),
424
+ ytd: () => ({ from: '2026-01-01', to: '2026-08-27' }),
425
+ },
426
+ }),
427
+ },
428
+ })
429
+
430
+ type SummaryParams = { window: '7d' | '30d' | '90d' | 'all' } | { from: string; to: string }
431
+
432
+ // The assignment IS the assertion — no cast, which is what a consumer's `resolveWindow` was.
433
+ const resolve = (preset: '7d' | '30d' | '90d' | '3m' | 'ytd' | 'all'): SummaryParams =>
434
+ store.field.window.toWindow({ preset })
435
+
436
+ expect(resolve('30d')).toEqual({ window: '30d' })
437
+ expect(resolve('3m')).toEqual({ from: '2026-06-01', to: '2026-08-27' })
438
+ expect(resolve('ytd')).toEqual({ from: '2026-01-01', to: '2026-08-27' })
439
+
440
+ // @ts-expect-error `3m` declared a resolver, so it can never come back in the window branch.
441
+ const pinned: { window: '3m' } | { from: string; to: string } = store.field.window.toWindow({
442
+ preset: '3m',
443
+ })
444
+ expect(pinned).toEqual({ from: '2026-06-01', to: '2026-08-27' })
445
+ })
446
+ })
447
+
448
+ /**
449
+ * The number handle's republished bounds (`NumberHandleExtras`). They are on the handle for exactly
450
+ * one reason: a control never sees the field descriptor, so before this the only way a `NumberFilter`
451
+ * could bound its own input was for the call site to pass `min`/`max` a second time — a second answer
452
+ * to a question the field already owns.
453
+ */
454
+ describe('createLocalStore — a number handle republishes its bounds', () => {
455
+ test('min, max and int come off the field declaration', () => {
456
+ const store = createLocalStore({
457
+ key: 'l-bounds',
458
+ fields: { nights: field.number({ fallback: 2, min: 1, max: 14, int: true }) },
459
+ })
460
+
461
+ expect({
462
+ min: store.field.nights.min,
463
+ max: store.field.nights.max,
464
+ int: store.field.nights.int,
465
+ }).toEqual({ min: 1, max: 14, int: true })
466
+ })
467
+
468
+ test('an undeclared bound is undefined, and int defaults to false — never 0/true by accident', () => {
469
+ const store = createLocalStore({
470
+ key: 'l-bounds-open',
471
+ fields: { threshold: field.number({ fallback: 0 }) },
472
+ })
473
+
474
+ expect({
475
+ min: store.field.threshold.min,
476
+ max: store.field.threshold.max,
477
+ int: store.field.threshold.int,
478
+ }).toEqual({ min: undefined, max: undefined, int: false })
479
+ })
480
+
481
+ // The negative branch of the extras type, asserted at RUNTIME as well: a non-number handle must
482
+ // not grow a `max` off `StringField`'s own `max`, which is the one collision this shape could have.
483
+ test('a non-number handle carries none of the three', () => {
484
+ const store = createLocalStore({
485
+ key: 'l-bounds-other',
486
+ fields: { q: field.string({ max: 40 }), metric: field.enum(['load', 'volume'], 'load') },
487
+ })
488
+
489
+ expect([store.field.q.max, store.field.q.min, store.field.q.int]).toEqual([
490
+ undefined,
491
+ undefined,
492
+ undefined,
493
+ ])
494
+ expect(store.field.metric.int).toBeUndefined()
495
+ })
496
+
497
+ // The bounds are what the codec was ALREADY clamping to — the handle is a readout of that law,
498
+ // not a second copy of it.
499
+ test('the value the codec clamps to is the value the handle reports', () => {
500
+ const store = createLocalStore({
501
+ key: 'l-bounds-clamp',
502
+ fields: { nights: field.number({ fallback: 2, min: 1, max: 14, int: true }) },
503
+ })
504
+ persist('l-bounds-clamp', { nights: 99 })
505
+
506
+ const { result } = renderHook(() => store.field.nights.use())
507
+ // Both sides in ONE assertion, so the pair has to agree rather than each matching 14 alone —
508
+ // `max` is `number | undefined` on the handle by construction, which is why it is not the
509
+ // `expected` argument.
510
+ expect([result.current[0], store.field.nights.max]).toEqual([14, 14])
511
+ })
512
+ })