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
package/src/index.ts CHANGED
@@ -99,9 +99,11 @@ export {
99
99
  type AnyField,
100
100
  type BooleanField,
101
101
  type EnumField,
102
+ type FieldFallback,
102
103
  type FieldHandle,
103
104
  type FieldLane,
104
105
  type FieldOption,
106
+ type FieldSetOptions,
105
107
  type FieldValue,
106
108
  type MultiField,
107
109
  type NumberField,
@@ -109,6 +111,8 @@ export {
109
111
  type RangeParams,
110
112
  type RangePresets,
111
113
  type RangeValue,
114
+ type RangeWindow,
115
+ type RangeWindows,
112
116
  type ResolvedLane,
113
117
  type SearchValues,
114
118
  type StoredValues,
@@ -126,6 +130,7 @@ export {
126
130
  DeltaBadge,
127
131
  type DeltaBadgeProps,
128
132
  StatCard,
133
+ type StatCardBreakdownRow,
129
134
  type StatCardProps,
130
135
  type StatCardTone,
131
136
  EmptyState,
@@ -14,15 +14,19 @@ export type {
14
14
  AnyField,
15
15
  BooleanField,
16
16
  EnumField,
17
+ FieldFallback,
17
18
  FieldHandle,
18
19
  FieldLane,
19
20
  FieldOption,
21
+ FieldSetOptions,
20
22
  FieldValue,
21
23
  MultiField,
22
24
  NumberField,
23
25
  RangeField,
24
26
  RangeParams,
25
27
  RangePresets,
28
+ RangeWindow,
29
+ RangeWindows,
26
30
  RangeValue,
27
31
  ResolvedLane,
28
32
  SearchValues,
@@ -14,9 +14,11 @@ export type {
14
14
  AnyField,
15
15
  BooleanField,
16
16
  EnumField,
17
+ FieldFallback,
17
18
  FieldHandle,
18
19
  FieldLane,
19
20
  FieldOption,
21
+ FieldSetOptions,
20
22
  FieldValue,
21
23
  MultiField,
22
24
  NumberField,
@@ -24,6 +26,8 @@ export type {
24
26
  RangeParams,
25
27
  RangePresets,
26
28
  RangeValue,
29
+ RangeWindow,
30
+ RangeWindows,
27
31
  ResolvedLane,
28
32
  SearchValues,
29
33
  StoredValues,
@@ -17,11 +17,11 @@ import {
17
17
  createRoute,
18
18
  createRouter,
19
19
  } from '@tanstack/react-router'
20
- import type { AnyField, FieldHandle } from '../state'
20
+ import type { AnyField, FieldHandle, FieldSetOptions } from '../state'
21
21
  import { field } from './field'
22
22
  import { createSearchStore } from './search-store'
23
23
 
24
- type Probe = { value: unknown; set: (next: unknown) => void }
24
+ type Probe = { value: unknown; set: (next: unknown, opts?: FieldSetOptions) => void }
25
25
  type Sink = { current: Probe | null }
26
26
 
27
27
  function sink(): Sink {
@@ -32,7 +32,7 @@ function sink(): Sink {
32
32
  function fieldProbe<F extends AnyField>(handle: FieldHandle<F>, into: Sink) {
33
33
  return function FieldProbe(): ReactNode {
34
34
  const [value, set] = handle.use()
35
- into.current = { value, set: set as (next: unknown) => void }
35
+ into.current = { value, set: set as Probe['set'] }
36
36
  return null
37
37
  }
38
38
  }
@@ -419,7 +419,7 @@ describe('store hooks', () => {
419
419
  expect(count).toBe(2)
420
420
  })
421
421
 
422
- test('useReset returns every field to its fallback in ONE navigate call', async () => {
422
+ test('useReset returns every field to its fallback in ONE navigate call, UNSETTING the mirror', async () => {
423
423
  const store = createSearchStore({
424
424
  key: 'r-reset',
425
425
  fields: {
@@ -451,14 +451,79 @@ describe('store hooks', () => {
451
451
  })
452
452
  expect(currentSearch(router)['compare']).toBe('none')
453
453
  expect(currentSearch(router)['from']).toBeUndefined()
454
- expect(storedRecord('r-reset')).toEqual({
455
- range: { preset: '30d' },
456
- compare: 'none',
457
- compact: false,
458
- })
454
+ // Reset means UNSET: the persisted keys are DELETED, not overwritten with the resolved
455
+ // fallback — writing it would pin a thunk fallback into the mirror (see the lazy-fallback test
456
+ // below) and leave the field counted as active tomorrow.
457
+ expect(storedRecord('r-reset')).toEqual({})
459
458
  navigate.mockRestore()
460
459
  })
461
460
 
461
+ test('useReset UNSETS a thunk fallback rather than pinning it into the mirror', async () => {
462
+ let today = '2026-08-27'
463
+ const store = createSearchStore({
464
+ key: 'r-lazy-reset',
465
+ fields: {
466
+ range: field.enum(['1d', '7d'], '7d'),
467
+ since: field.string({ fallback: () => today }, { url: false }),
468
+ },
469
+ })
470
+ persist('r-lazy-reset', { since: '2026-01-01' })
471
+ const probe = sink()
472
+ let reset: (() => void) | null = null
473
+ let count = -1
474
+
475
+ await mountApp({
476
+ validateSearch: store.validateSearch,
477
+ entry: '/dashboard',
478
+ Dashboard: () => {
479
+ reset = store.useReset()
480
+ count = store.useActiveCount()
481
+ return fieldProbe(store.field.since, probe)()
482
+ },
483
+ })
484
+ expect(probe.current?.value).toBe('2026-01-01')
485
+
486
+ await act(async () => {
487
+ reset?.()
488
+ })
489
+
490
+ expect(probe.current?.value).toBe('2026-08-27')
491
+ expect(storedRecord('r-lazy-reset')).toEqual({})
492
+ expect(count).toBe(0)
493
+
494
+ // The next day the same field reads TODAY, not the day the reset was pressed — which is the
495
+ // whole point of a thunk fallback, and what writing the resolved value took away.
496
+ today = '2026-08-28'
497
+ expect(store.field.since.fallback).toBe('2026-08-28')
498
+ expect(store.readStored()['since']).toBeUndefined()
499
+ })
500
+
501
+ test('field.clear() returns the URL param to the fallback and drops the mirror key', async () => {
502
+ const store = createSearchStore({
503
+ key: 'r-clear-url',
504
+ fields: { range: field.enum(['1d', '7d', '30d'], '7d') },
505
+ })
506
+ persist('r-clear-url', { range: '30d' })
507
+ const probe = sink()
508
+
509
+ const router = await mountApp({
510
+ validateSearch: store.validateSearch,
511
+ entry: '/dashboard?range=30d',
512
+ Dashboard: fieldProbe(store.field.range, probe),
513
+ })
514
+ expect(probe.current?.value).toBe('30d')
515
+
516
+ await act(async () => {
517
+ store.field.range.clear()
518
+ })
519
+
520
+ await waitFor(() => {
521
+ expect(currentSearch(router)['range']).toBe('7d')
522
+ })
523
+ expect(storedRecord('r-clear-url')).toEqual({})
524
+ expect(probe.current?.value).toBe('7d')
525
+ })
526
+
462
527
  test('useReset from a FOREIGN route persists only — it never navigates (A1)', async () => {
463
528
  const store = createSearchStore({
464
529
  key: 'r-reset-foreign',
@@ -636,8 +701,31 @@ describe('the memory-only lane — url: false, persist: false', () => {
636
701
  })
637
702
  expect(probe.current?.value).toBe('a')
638
703
  expect(count).toBe(0)
639
- // `useReset` wrote the MIRROR for the persisted field beside it; the memory field is not in it.
640
- expect(storedRecord('r-memory')).toEqual({ range: '7d' })
704
+ // Nothing was ever persisted, so the reset had no key to drop and wrote no envelope — the
705
+ // mirror only ever holds values a control produced.
706
+ expect(storedRecord('r-memory')).toBeNull()
707
+ })
708
+
709
+ test('field.clear() drops the memory value, and only that one', async () => {
710
+ const store = memoryStore()
711
+ const probe = sink()
712
+
713
+ await mountApp({
714
+ validateSearch: store.validateSearch,
715
+ entry: '/dashboard',
716
+ Dashboard: fieldProbe(store.field.scratch, probe),
717
+ })
718
+
719
+ await act(async () => {
720
+ probe.current?.set('c')
721
+ })
722
+ expect(probe.current?.value).toBe('c')
723
+
724
+ await act(async () => {
725
+ store.field.scratch.clear()
726
+ })
727
+ expect(probe.current?.value).toBe('a')
728
+ expect(storedRecord('r-memory')).toBeNull()
641
729
  })
642
730
 
643
731
  test('two mounts of the same field see one value', async () => {
@@ -659,3 +747,136 @@ describe('the memory-only lane — url: false, persist: false', () => {
659
747
  expect(b.current?.value).toBe('b')
660
748
  })
661
749
  })
750
+
751
+ /**
752
+ * `set(next, { patch })` — the sibling params a page owns and the store does not. Clearing one used
753
+ * to need a second `navigate` beside the setter, which either raced the field's own write or left
754
+ * two history entries where the reader made one change.
755
+ */
756
+ describe('use() — a patched write', () => {
757
+ const store = createSearchStore({
758
+ key: 'r-patch',
759
+ fields: { tab: field.enum(['overview', 'detail'], 'overview') },
760
+ })
761
+
762
+ /** The store's params PLUS one the page owns — the shape a real route composes. */
763
+ const validateSearch = (raw: Record<string, unknown>): Record<string, unknown> => ({
764
+ ...store.validateSearch(raw),
765
+ detailDate: typeof raw['detailDate'] === 'string' ? raw['detailDate'] : undefined,
766
+ })
767
+
768
+ test('clears a sibling param the store does not own, in the same navigate', async () => {
769
+ const probe = sink()
770
+ const router = await mountApp({
771
+ validateSearch,
772
+ entry: '/dashboard?tab=detail&detailDate=2026-03-01',
773
+ Dashboard: fieldProbe(store.field.tab, probe),
774
+ })
775
+ expect(currentSearch(router)['detailDate']).toBe('2026-03-01')
776
+
777
+ await act(async () => {
778
+ probe.current?.set('overview', { patch: { detailDate: undefined } })
779
+ })
780
+
781
+ await waitFor(() => {
782
+ expect(currentSearch(router)['tab']).toBe('overview')
783
+ })
784
+ expect(currentSearch(router)['detailDate']).toBeUndefined()
785
+ expect(probe.current?.value).toBe('overview')
786
+ // The field's own lane is untouched by the patch — the write still mirrors.
787
+ expect(storedRecord('r-patch')).toEqual({ tab: 'overview' })
788
+ })
789
+
790
+ test('a patch cannot overwrite the value being set', async () => {
791
+ const probe = sink()
792
+ const router = await mountApp({
793
+ validateSearch,
794
+ entry: '/dashboard?tab=overview',
795
+ Dashboard: fieldProbe(store.field.tab, probe),
796
+ })
797
+
798
+ await act(async () => {
799
+ probe.current?.set('detail', { patch: { tab: 'overview', detailDate: '2026-04-01' } })
800
+ })
801
+
802
+ await waitFor(() => {
803
+ expect(currentSearch(router)['tab']).toBe('detail')
804
+ })
805
+ expect(currentSearch(router)['detailDate']).toBe('2026-04-01')
806
+ })
807
+
808
+ test('a patch naming a param ANOTHER field owns is refused, not silently desynced', async () => {
809
+ const twoFields = createSearchStore({
810
+ key: 'r-patch-owned',
811
+ fields: {
812
+ tab: field.enum(['overview', 'detail'], 'overview'),
813
+ range: field.enum(['7d', '30d'], '30d'),
814
+ },
815
+ })
816
+ const probe = sink()
817
+ const router = await mountApp({
818
+ validateSearch: twoFields.validateSearch,
819
+ entry: '/dashboard',
820
+ Dashboard: fieldProbe(twoFields.field.tab, probe),
821
+ })
822
+
823
+ // The URL would take `range=7d` while the mirror kept `30d`, so the next paramless visit and
824
+ // every `linkSearch` link would disagree with the page the write happened on.
825
+ expect(() => {
826
+ probe.current?.set('detail', { patch: { range: '7d' } })
827
+ }).toThrow(/field 'range' owns/)
828
+ expect(currentSearch(router)['tab']).toBe('overview')
829
+ expect(currentSearch(router)['range']).toBe('30d')
830
+ })
831
+
832
+ test('a patched write from a FOREIGN route drops the patch, and warns once', async () => {
833
+ const store = createSearchStore({
834
+ key: 'r-patch-foreign',
835
+ fields: { tab: field.enum(['overview', 'detail'], 'overview') },
836
+ })
837
+ const probe = sink()
838
+ const warn = spyOn(console, 'warn').mockImplementation(() => {})
839
+
840
+ const router = await mountApp({
841
+ validateSearch: store.validateSearch,
842
+ entry: '/other?detailDate=2026-03-01',
843
+ Other: fieldProbe(store.field.tab, probe),
844
+ })
845
+
846
+ await act(async () => {
847
+ probe.current?.set('detail', { patch: { detailDate: undefined } })
848
+ })
849
+
850
+ // The write persisted (A1); the patch had no navigate to merge into and is gone.
851
+ expect(storedRecord('r-patch-foreign')).toEqual({ tab: 'detail' })
852
+ expect(currentSearch(router)['detailDate']).toBe('2026-03-01')
853
+ expect(warn).toHaveBeenCalledTimes(1)
854
+
855
+ await act(async () => {
856
+ probe.current?.set('overview', { patch: { detailDate: undefined } })
857
+ })
858
+ expect(warn).toHaveBeenCalledTimes(1)
859
+ warn.mockRestore()
860
+ })
861
+
862
+ test('a field with no URL lane has no navigate to patch, and does not make one', async () => {
863
+ const local = createSearchStore({
864
+ key: 'r-patch-local',
865
+ fields: { compact: field.boolean(false, { url: false }) },
866
+ })
867
+ const probe = sink()
868
+ const router = await mountApp({
869
+ validateSearch,
870
+ entry: '/dashboard?tab=overview',
871
+ Dashboard: fieldProbe(local.field.compact, probe),
872
+ })
873
+
874
+ await act(async () => {
875
+ probe.current?.set(true, { patch: { detailDate: '2026-05-01' } })
876
+ })
877
+
878
+ expect(probe.current?.value).toBe(true)
879
+ expect(currentSearch(router)['detailDate']).toBeUndefined()
880
+ expect(storedRecord('r-patch-local')).toEqual({ compact: true })
881
+ })
882
+ })
@@ -396,6 +396,78 @@ describe('the handle, outside React', () => {
396
396
  expect(store.field.range.toWindow({ preset: 'custom' })).toEqual({ window: 'custom' })
397
397
  })
398
398
 
399
+ test('a preset declared with a `window` resolver projects to `{ from, to }`', () => {
400
+ // The derived presets three consumers hand-rolled beside the store (`3m`, `ytd`): one preset in
401
+ // the URL, two dates at the API boundary, no `presetToParams` in the page.
402
+ const seen: Date[] = []
403
+ const store = createSearchStore({
404
+ key: 'k-window-resolver',
405
+ fields: {
406
+ range: field.range({
407
+ presets: ['7d', '3m', 'ytd'],
408
+ fallback: '7d',
409
+ custom: true,
410
+ window: {
411
+ '3m': (now) => {
412
+ seen.push(now)
413
+ return { from: iso(addMonths(now, -3)), to: iso(now) }
414
+ },
415
+ ytd: (now) => ({ from: `${now.getUTCFullYear()}-01-01`, to: iso(now) }),
416
+ },
417
+ }),
418
+ },
419
+ })
420
+
421
+ const now = new Date()
422
+ expect(store.field.range.toWindow({ preset: '3m' })).toEqual({
423
+ from: iso(addMonths(now, -3)),
424
+ to: iso(now),
425
+ })
426
+ expect(store.field.range.toWindow({ preset: 'ytd' })).toEqual({
427
+ from: `${now.getUTCFullYear()}-01-01`,
428
+ to: iso(now),
429
+ })
430
+ // Resolved at CALL time, with the current date — never captured at definition.
431
+ expect(seen[0] instanceof Date).toBe(true)
432
+
433
+ // A preset WITHOUT a resolver is untouched, in the same field.
434
+ expect(store.field.range.toWindow({ preset: '7d' })).toEqual({ window: '7d' })
435
+ // And a custom window still wins over any resolver.
436
+ expect(
437
+ store.field.range.toWindow({ preset: 'custom', from: '2026-03-01', to: '2026-03-14' }),
438
+ ).toEqual({ from: '2026-03-01', to: '2026-03-14' })
439
+ })
440
+
441
+ test('a resolver changes nothing about the URL — the preset is still what is stored', () => {
442
+ const store = createSearchStore({
443
+ key: 'k-window-url',
444
+ fields: {
445
+ range: field.range({
446
+ presets: ['7d', '3m'],
447
+ fallback: '7d',
448
+ window: { '3m': () => ({ from: '2026-01-01', to: '2026-04-01' }) },
449
+ }),
450
+ },
451
+ })
452
+ expect(store.validateSearch({ range: '3m' })).toEqual({
453
+ range: '3m',
454
+ from: undefined,
455
+ to: undefined,
456
+ })
457
+ expect(store.field.range.toWindow({ preset: '3m' })).toEqual({
458
+ from: '2026-01-01',
459
+ to: '2026-04-01',
460
+ })
461
+ })
462
+
463
+ test('a preset named like an Object.prototype member is not a resolver', () => {
464
+ const store = createSearchStore({
465
+ key: 'k-window-proto',
466
+ fields: { range: field.range({ presets: ['toString', '7d'], fallback: '7d' }) },
467
+ })
468
+ expect(store.field.range.toWindow({ preset: 'toString' })).toEqual({ window: 'toString' })
469
+ })
470
+
399
471
  test('toWindow exists on a range handle and on no other kind', () => {
400
472
  const store = createSearchStore({
401
473
  key: 'k-towindow-absent',
@@ -416,3 +488,91 @@ describe('types', () => {
416
488
  expect(store.field.tab.kind).toBe('enum')
417
489
  })
418
490
  })
491
+
492
+ /** UTC `YYYY-MM-DD` — the shape `toWindow` deals in, with no timezone in the answer. */
493
+ function iso(d: Date): string {
494
+ return d.toISOString().slice(0, 10)
495
+ }
496
+
497
+ function addMonths(d: Date, months: number): Date {
498
+ const next = new Date(d)
499
+ next.setUTCMonth(next.getUTCMonth() + months)
500
+ return next
501
+ }
502
+
503
+ describe('a lazy fallback and the lanes', () => {
504
+ test('a thunk fallback on the URL lane throws at DEFINITION, naming the two ways out', () => {
505
+ expect(() =>
506
+ createSearchStore({
507
+ key: 'k-lazy-url',
508
+ fields: { since: field.string({ fallback: () => '2026-01-01' }) },
509
+ }),
510
+ ).toThrow(/thunk fallback on the URL lane/)
511
+ })
512
+
513
+ test('off the URL lane it resolves per read, in a search store too', () => {
514
+ let today = '2026-01-01'
515
+ const store = createSearchStore({
516
+ key: 'k-lazy-local',
517
+ fields: {
518
+ since: field.string({ fallback: () => today }, { url: false }),
519
+ scratch: field.string({ fallback: () => today }, { url: false, persist: false }),
520
+ },
521
+ })
522
+ expect(store.field.since.fallback).toBe('2026-01-01')
523
+ today = '2026-02-01'
524
+ expect(store.field.since.fallback).toBe('2026-02-01')
525
+ expect(store.field.scratch.fallback).toBe('2026-02-01')
526
+ })
527
+ })
528
+
529
+ describe('field.range — the `custom` flag is inferred, never widened', () => {
530
+ test('an omitted `custom` keeps `custom` out of the value type AND out of the options', () => {
531
+ const store = createSearchStore({
532
+ key: 'k-custom-inference',
533
+ fields: { range: field.range({ presets: ['7d', '30d'], fallback: '30d' }) },
534
+ })
535
+ type Search = ReturnType<typeof store.validateSearch>
536
+
537
+ const preset: Search['range'] = '7d'
538
+ // @ts-expect-error 'custom' is NOT a value of a range that never declared `custom: true` —
539
+ // this is the whole bug: written inline in `fields`, `C` used to widen to `boolean` against
540
+ // `AnyField`, and every value type gained a preset the field rejects at runtime.
541
+ const widened: Search['range'] = 'custom'
542
+
543
+ expect(preset).toBe('7d')
544
+ // `String(...)` because `widened`'s DECLARED type no longer admits the value it holds — the
545
+ // `@ts-expect-error` above is the assertion, this only keeps the binding used.
546
+ expect(String(widened)).toBe('custom')
547
+ expect(store.field.range.options.map((option) => option.value)).toEqual(['7d', '30d'])
548
+ // The runtime agrees with the type: a `custom` preset does not decode.
549
+ expect(store.validateSearch({ range: 'custom', from: '2026-01-01', to: '2026-02-01' })).toEqual(
550
+ { range: '30d', from: undefined, to: undefined },
551
+ )
552
+ })
553
+
554
+ test('`custom: true` still admits it, in the type and in the options', () => {
555
+ const store = createSearchStore({
556
+ key: 'k-custom-true',
557
+ fields: { range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }) },
558
+ })
559
+ type Search = ReturnType<typeof store.validateSearch>
560
+ const custom: Search['range'] = 'custom'
561
+
562
+ expect(custom).toBe('custom')
563
+ expect(store.field.range.options.map((option) => option.value)).toEqual(['7d', '30d', 'custom'])
564
+ })
565
+
566
+ test('a `custom` flag held in a widened boolean still builds — it just stays widened', () => {
567
+ const allowCustom: boolean = true
568
+ const store = createSearchStore({
569
+ key: 'k-custom-widened',
570
+ fields: { range: field.range({ presets: ['7d'], fallback: '7d', custom: allowCustom }) },
571
+ })
572
+ type Search = ReturnType<typeof store.validateSearch>
573
+ const custom: Search['range'] = 'custom'
574
+
575
+ expect(custom).toBe('custom')
576
+ expect(store.field.range.options.map((option) => option.value)).toEqual(['7d', 'custom'])
577
+ })
578
+ })