basalt-ui 2.2.0 → 2.4.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 (63) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/MIGRATING.md +78 -12
  3. package/agent/rules/basalt-state.md +1 -1
  4. package/configs/lefthook.yml +4 -1
  5. package/dist/charts/BandStrip.d.ts +7 -7
  6. package/dist/charts/BandStrip.d.ts.map +1 -1
  7. package/dist/charts/Bars.d.ts +4 -4
  8. package/dist/charts/Bars.d.ts.map +1 -1
  9. package/dist/charts/CartesianChart.d.ts.map +1 -1
  10. package/dist/charts/CartesianChart.js +6 -2
  11. package/dist/charts/ChartCard.d.ts +10 -10
  12. package/dist/charts/ChartCard.d.ts.map +1 -1
  13. package/dist/charts/ChartCard.js +2 -1
  14. package/dist/charts/Donut.d.ts +5 -5
  15. package/dist/charts/Donut.d.ts.map +1 -1
  16. package/dist/charts/DualPanel.d.ts +14 -10
  17. package/dist/charts/DualPanel.d.ts.map +1 -1
  18. package/dist/charts/DualPanel.js +32 -14
  19. package/dist/charts/Heatmap.d.ts +15 -15
  20. package/dist/charts/Heatmap.d.ts.map +1 -1
  21. package/dist/charts/Heatmap.js +2 -2
  22. package/dist/charts/Lines.js +2 -2
  23. package/dist/charts/MirroredBars.d.ts +9 -9
  24. package/dist/charts/MirroredBars.d.ts.map +1 -1
  25. package/dist/charts/MirroredBars.js +8 -7
  26. package/dist/charts/Sparkline.d.ts +6 -6
  27. package/dist/charts/Sparkline.d.ts.map +1 -1
  28. package/dist/charts/StackedArea.d.ts +2 -2
  29. package/dist/charts/StackedArea.d.ts.map +1 -1
  30. package/dist/charts/compile.d.ts +25 -2
  31. package/dist/charts/compile.d.ts.map +1 -1
  32. package/dist/charts/compile.js +204 -34
  33. package/dist/charts/index.d.ts +1 -0
  34. package/dist/charts/index.d.ts.map +1 -1
  35. package/dist/charts/index.js +3 -1
  36. package/dist/charts/marks.d.ts +8 -0
  37. package/dist/charts/marks.d.ts.map +1 -1
  38. package/dist/charts/marks.js +22 -1
  39. package/dist/charts/panel.js +2 -2
  40. package/dist/charts/types.d.ts +101 -61
  41. package/dist/charts/types.d.ts.map +1 -1
  42. package/dist/state/fields.d.ts +19 -3
  43. package/dist/state/fields.d.ts.map +1 -1
  44. package/dist/state/fields.js +14 -8
  45. package/llms.txt +2 -2
  46. package/package.json +1 -1
  47. package/src/charts/BandStrip.tsx +12 -10
  48. package/src/charts/Bars.tsx +4 -4
  49. package/src/charts/CartesianChart.tsx +7 -2
  50. package/src/charts/ChartCard.tsx +17 -11
  51. package/src/charts/Donut.tsx +5 -5
  52. package/src/charts/DualPanel.tsx +50 -21
  53. package/src/charts/Heatmap.tsx +14 -14
  54. package/src/charts/Lines.tsx +2 -2
  55. package/src/charts/MirroredBars.tsx +16 -15
  56. package/src/charts/Sparkline.tsx +6 -6
  57. package/src/charts/StackedArea.tsx +2 -2
  58. package/src/charts/compile.ts +285 -36
  59. package/src/charts/index.ts +1 -0
  60. package/src/charts/marks.ts +35 -1
  61. package/src/charts/panel.tsx +2 -2
  62. package/src/charts/types.ts +105 -57
  63. package/src/state/fields.ts +55 -19
@@ -24,35 +24,57 @@ export type ChartSeries<T> = {
24
24
  * series whose `getValue` is always `null` is a legend-only entry (a zone or reference key). */
25
25
  getValue: (d: T) => number | null
26
26
  /** The legend swatch shape. Default: the kind's own mark (`line` in `Lines`, `bar` in `Bars`). */
27
- mark?: 'line' | 'bar' | 'area'
27
+ mark?: 'line' | 'bar' | 'area' | undefined
28
28
  /** Default `'solid'`; `'dashed'` is `VX.dashArray` on the line, the legend and the tooltip. */
29
- dash?: 'solid' | 'dashed'
29
+ dash?: 'solid' | 'dashed' | undefined
30
30
  /** Interpolation between points. Default `'monotone'`. A piecewise-constant quantity (a price
31
31
  * tier, a config value) is a step: a smoothed curve draws values that never existed. */
32
- curve?: CurveName
33
- strokeWidth?: number
32
+ curve?: CurveName | undefined
33
+ strokeWidth?: number | undefined
34
+ /**
35
+ * What a `null` value does to the line (and its `area`). `'break'` (default) leaves a gap;
36
+ * `'connect'` draws through the series' non-null points only, skipping the null rows, so a series
37
+ * that is null on most shared x values (one lift in a wide table) is one continuous line. Points,
38
+ * markers, the cursor and the tooltip still read real values: a null row has no tooltip row.
39
+ */
40
+ gaps?: 'break' | 'connect' | undefined
34
41
  /** Dims the plotted stroke (a faint companion line). Strokes only. */
35
- strokeOpacity?: number
42
+ strokeOpacity?: number | undefined
36
43
  /** Bars and areas. */
37
- fillOpacity?: number
38
- /** `Lines`: a soft gradient fill under the line — `true` in the series color, or a color ref. */
39
- area?: boolean | string
44
+ fillOpacity?: number | undefined
45
+ /** `Lines`: a soft gradient fill under the line — `true` in the series color, a color ref, or
46
+ * `'zones'`: a vertical gradient coloured by the chart's `zones` on this series' axis, hard stops
47
+ * at the zone boundaries (no zone in reach: the series color). Linear axes only. */
48
+ area?: boolean | 'zones' | (string & {}) | undefined
40
49
  /** Which y axis the series is measured against. `'right'` needs the chart's `y2`. */
41
- axis?: 'left' | 'right'
50
+ axis?: 'left' | 'right' | undefined
42
51
  /** Legend grouping: series → overlay → reference. Default `'series'`. */
43
- role?: 'series' | 'overlay' | 'reference'
52
+ role?: 'series' | 'overlay' | 'reference' | undefined
44
53
  /** `false` keeps it out of the legend (a companion line folded under `parent`). Default `true`. */
45
- legend?: boolean
54
+ legend?: boolean | undefined
46
55
  /** `false` draws and legends it but never lists it as a tooltip row. Default `true`. */
47
- tooltip?: boolean
56
+ tooltip?: boolean | undefined
48
57
  /** A companion (an MA line) names its parent, so a legend toggle and hover keep the pair. */
49
- parent?: string
58
+ parent?: string | undefined
50
59
  /** A short qualifier after the label in the legend and the tooltip row, in muted text. */
51
- note?: string
60
+ note?: string | undefined
52
61
  /** Per-series tooltip value format; receives the datum so a row can read more than the value. */
53
- formatValue?: (v: number, d: T) => string
62
+ formatValue?: ((v: number, d: T) => string) | undefined
63
+ /**
64
+ * A cursor dot for a series whose mark the consumer draws itself (`marks`, so `getValue` stays
65
+ * `null`): the y of the dot at each datum, `null` for none. The dot shows while the cursor is on
66
+ * that datum's x and takes the series color. A series drawn from `plot.rows` already has one.
67
+ */
68
+ cursorValue?: ((d: T) => number | null) | undefined
54
69
  /** A marker at a point (a PR star, a status dot); `null` for none. */
55
- getMarker?: (d: T) => { color?: string; r?: number; fillOpacity?: number; ring?: boolean } | null
70
+ getMarker?:
71
+ | ((d: T) => {
72
+ color?: string | undefined
73
+ r?: number | undefined
74
+ fillOpacity?: number | undefined
75
+ ring?: boolean | undefined
76
+ } | null)
77
+ | undefined
56
78
  }
57
79
 
58
80
  /** One y axis. */
@@ -66,23 +88,33 @@ export type AxisConfig<T> = {
66
88
  | readonly [number, number]
67
89
  | 'auto'
68
90
  | ((data: readonly T[], visible: readonly ChartSeries<T>[]) => readonly [number, number])
91
+ | undefined
69
92
  /** When `'auto'`: the raw upper bound is at least this; padding applies after. */
70
- autoMaxFloor?: number
93
+ autoMaxFloor?: number | undefined
71
94
  /** When `'auto'`: the lower bound is at most this. Default `0` (a zero baseline); `Infinity` pads
72
95
  * down from the data minimum instead. Ignored on a log axis. */
73
- autoMinCeil?: number
96
+ autoMinCeil?: number | undefined
74
97
  /** When `'auto'`: padding multiplier away from the data. Default `1.1`. */
75
- autoPad?: number
98
+ autoPad?: number | undefined
76
99
  /** A tick count, or the exact tick values. Default: one per ~48px of plot height, ≤ 5. */
77
- ticks?: number | readonly number[]
100
+ ticks?: number | readonly number[] | undefined
78
101
  /** Tick and tooltip formatter. Default: compact numbers (`12.5k`). */
79
- format?: (v: number) => string
102
+ format?: ((v: number) => string) | undefined
80
103
  /** Horizontal grid rules. Default: on for `y`, off for `y2`. */
81
- grid?: boolean
104
+ grid?: boolean | undefined
82
105
  /** Round the domain outward to nice ticks. Default `false` (a fixed domain stays fixed). */
83
- nice?: boolean
106
+ nice?: boolean | undefined
84
107
  /** Default `'linear'`. A log axis floors at the smallest positive visible value. */
85
- scale?: 'linear' | 'log'
108
+ scale?: 'linear' | 'log' | undefined
109
+ }
110
+
111
+ /** A rule along a band's boundary, painted with the band. A bound at the plot edge draws none. */
112
+ export type ZoneEdge<S extends string> = {
113
+ side: S | 'both'
114
+ color: string
115
+ dashed?: boolean | undefined
116
+ /** Default `1`. */
117
+ width?: number | undefined
86
118
  }
87
119
 
88
120
  /** A horizontal band on one y axis. `±Infinity` reaches the plot edge. */
@@ -90,7 +122,10 @@ export type ZoneSpec = {
90
122
  from: number
91
123
  to: number
92
124
  fill: string
93
- axisSide?: 'left' | 'right'
125
+ axisSide?: 'left' | 'right' | undefined
126
+ /** A rule on the band's visual `top` (the larger bound) and/or `bottom` (the smaller), whichever
127
+ * of `from`/`to` that is. */
128
+ edge?: ZoneEdge<'top' | 'bottom'> | undefined
94
129
  }
95
130
 
96
131
  /**
@@ -99,18 +134,27 @@ export type ZoneSpec = {
99
134
  * from one hour to another, both hours included).
100
135
  */
101
136
  export type XZoneSpec<X extends ChartX = ChartX> = {
102
- from?: X
103
- to?: X
137
+ from?: X | undefined
138
+ to?: X | undefined
104
139
  fill: string
105
- align?: 'center' | 'edge'
140
+ align?: 'center' | 'edge' | undefined
141
+ /** A rule on the band's `start` (`from`) and/or `end` (`to`) boundary; an omitted bound has none. */
142
+ edge?: ZoneEdge<'start' | 'end'> | undefined
106
143
  }
107
144
 
108
145
  /** A horizontal reference line — annotation only, no domain role beyond staying inside the plot. */
109
146
  export type RefLine = {
110
147
  value: number
111
148
  color: string
112
- dashed?: boolean
113
- axis?: 'left' | 'right'
149
+ dashed?: boolean | undefined
150
+ axis?: 'left' | 'right' | undefined
151
+ /**
152
+ * Names the line in the legend (its color, dashed as `dashed`; a toggle hides the line), so a
153
+ * threshold needs no always-`null` series. `mark` is the swatch shape. Default `'line'`. The
154
+ * toggle state is keyed by the line's index in `refLines`: reordering the array while a line is
155
+ * toggled off moves the "off" to whichever line now sits at that index.
156
+ */
157
+ legend?: { label: string; mark?: 'line' | 'bar' | undefined } | undefined
114
158
  }
115
159
 
116
160
  /** A fill between the line and a value, on one side of it, tracking the line. */
@@ -128,50 +172,54 @@ export type ChartTooltipRow = {
128
172
  label: string
129
173
  value: string
130
174
  /** The row swatch; omitted, the row has none. */
131
- color?: string
175
+ color?: string | undefined
176
+ /** The swatch shape; `'dashed'` for a reference or dashed series. Default `'solid'`. */
177
+ swatch?: 'solid' | 'dashed' | undefined
132
178
  }
133
179
 
134
180
  export type ChartTooltipConfig<T, X extends ChartX = ChartX> = {
135
181
  /** The tooltip title. Default: the x value through `formatX`'s long form (a date reads
136
182
  * `Mon Sep 1 2026`). */
137
- formatHeader?: (x: X, d: T) => string
183
+ formatHeader?: ((x: X, d: T) => string) | undefined
138
184
  /** Rows after the per-series rows (a total, a zone badge, a context line). `visible`/`hidden` are
139
185
  * the legend's split, so a row never names a series the plot no longer draws. */
140
- rows?: (
141
- d: T,
142
- ctx: { visible: readonly ChartSeries<T>[]; hidden: readonly ChartSeries<T>[] },
143
- ) => readonly ChartTooltipRow[]
186
+ rows?:
187
+ | ((
188
+ d: T,
189
+ ctx: { visible: readonly ChartSeries<T>[]; hidden: readonly ChartSeries<T>[] },
190
+ ) => readonly ChartTooltipRow[])
191
+ | undefined
144
192
  /** Show this chart's tooltip while it FOLLOWS a sibling's cursor. Default `false`: only the chart
145
193
  * under the pointer or keyboard focus shows a tooltip and a live region. */
146
- onFollow?: boolean
194
+ onFollow?: boolean | undefined
147
195
  }
148
196
 
149
197
  export type ChartLegendConfig = {
150
198
  /** Default `'bottom'`. `'header'` draws the legend at the top of the chart body, spaced as the
151
199
  * enclosing `ChartCard`'s header legend row (in the chart's own markup, so it is in the server
152
200
  * HTML and the header never grows); outside a card it is `'top'`. */
153
- placement?: 'top' | 'bottom' | 'header'
201
+ placement?: 'top' | 'bottom' | 'header' | undefined
154
202
  /** Visually separate roles: series | overlay | reference. */
155
- groups?: boolean
203
+ groups?: boolean | undefined
156
204
  /** Clicking an entry hides that series. Default: on whenever there are two or more entries. */
157
- toggle?: boolean
205
+ toggle?: boolean | undefined
158
206
  }
159
207
 
160
208
  /** A height per container class, `base` below `regular`. A plain number is fixed. */
161
209
  export type ResponsiveChartHeight = {
162
210
  base: number
163
- regular?: number
164
- wide?: number
211
+ regular?: number | undefined
212
+ wide?: number | undefined
165
213
  }
166
214
 
167
215
  /** The three "nothing to draw" states, pending → error → empty. */
168
216
  export type ChartState = {
169
- pending?: boolean
217
+ pending?: boolean | undefined
170
218
  /** Truthy = the query failed; an `Error`'s message is the label. */
171
219
  error?: unknown
172
220
  /** `true`, or the empty label itself. The label is the one way to say WHY (`'No runs match the
173
221
  * filter'` vs `'No runs yet'`): § 9 fixes the state shape, so there is no reason field. */
174
- empty?: boolean | string
222
+ empty?: boolean | string | undefined
175
223
  }
176
224
 
177
225
  /** A row of `plot.rows(key)`: one per datum, `y: null` kept as a gap. */
@@ -215,27 +263,27 @@ export type CartesianChartProps<T, X extends ChartX = ChartX> = BasaltProps & {
215
263
  series: readonly ChartSeries<T>[]
216
264
  /** The plot's marks. Draw `plot.rows(key)` / `plot.visible`, never `series` directly. */
217
265
  marks: (plot: PlotContext<T, X>) => ChartMarks
218
- y?: AxisConfig<T>
266
+ y?: AxisConfig<T> | undefined
219
267
  /** A right axis; series with `axis: 'right'` and marks bound to `plot.y2` read it. */
220
- y2?: AxisConfig<T>
268
+ y2?: AxisConfig<T> | undefined
221
269
  /** A tick count, or the exact x tick values. Default: by container class. */
222
- xTicks?: number | readonly X[]
270
+ xTicks?: number | readonly X[] | undefined
223
271
  /** Tick label for an x value. Default: `DD.MM` for dates, compact numbers, strings verbatim. */
224
- formatX?: (x: X) => string
225
- zones?: readonly ZoneSpec[]
226
- xZones?: readonly XZoneSpec<X>[]
227
- refLines?: readonly RefLine[]
272
+ formatX?: ((x: X) => string) | undefined
273
+ zones?: readonly ZoneSpec[] | undefined
274
+ xZones?: readonly XZoneSpec<X>[] | undefined
275
+ refLines?: readonly RefLine[] | undefined
228
276
  /** `false` hides the legend. */
229
- legend?: ChartLegendConfig | false
277
+ legend?: ChartLegendConfig | false | undefined
230
278
  /** Default: the height law (measured width × class ratio, 160–420px, ≤ 45vh on touch). */
231
- height?: number | ResponsiveChartHeight
232
- state?: ChartState
279
+ height?: number | ResponsiveChartHeight | undefined
280
+ state?: ChartState | undefined
233
281
  /** Joins a named cursor group instead of the page-shared default. */
234
- cursorGroup?: string
282
+ cursorGroup?: string | undefined
235
283
  /** Default `'nearest'`. `'leading'` for a series keyed by a bucket's leading edge (a weekly
236
284
  * value keyed by its Monday). */
237
- cursorResolution?: CursorResolution
238
- tooltip?: ChartTooltipConfig<T, X> | false
285
+ cursorResolution?: CursorResolution | undefined
286
+ tooltip?: ChartTooltipConfig<T, X> | false | undefined
239
287
  }
240
288
 
241
289
  /** The x channel of a `plot.rows()` row, as an accessor (a field name does not type-check against a
@@ -279,6 +279,24 @@ type NumberHandleExtras<F> = F extends { kind: 'number' }
279
279
  readonly step?: undefined
280
280
  }
281
281
 
282
+ /**
283
+ * `createLocalStore`'s `use()` read options. `fallback` is a CALL-SITE default for a value that
284
+ * arrives as a prop (a leader, a latest date): it applies only while the field is unset (nothing
285
+ * stored), is applied in render AFTER the store read, and is never persisted — `clear()` unsets
286
+ * back to it. `handle.fallback` and `isDefault` still report the definition's.
287
+ *
288
+ * An object fallback (range, multi) must be memoised by the caller: the value returned while unset
289
+ * IS that object, so a fresh literal per render changes identity every render.
290
+ */
291
+ export type FieldReadOptions<F extends AnyField> = { fallback?: FieldValue<F> }
292
+
293
+ /** A `createLocalStore` field: the shared handle, plus `use({ fallback })`. */
294
+ export type LocalFieldHandle<F extends AnyField> = FieldHandle<F> & {
295
+ use(
296
+ read?: FieldReadOptions<F>,
297
+ ): readonly [FieldValue<F>, (next: FieldValue<F>, opts?: FieldSetOptions) => void]
298
+ }
299
+
282
300
  /**
283
301
  * The binding every basalt control takes instead of `value`/`onChange` (C2). One field, both lanes,
284
302
  * one resolution law. Handed out by `createSearchStore().field.<name>` /
@@ -771,7 +789,7 @@ type StoreRecord = Record<string, unknown>
771
789
  export type FieldWrite = (next: unknown, opts?: FieldSetOptions) => void
772
790
 
773
791
  /** One field's `use()`, untyped. @internal — see `FieldCodec`. */
774
- export type FieldUse = () => readonly [unknown, FieldWrite]
792
+ export type FieldUse = (read?: FieldReadOptions<AnyField>) => readonly [unknown, FieldWrite]
775
793
 
776
794
  /** One named field plus its codec, in declaration order. @internal — see `FieldCodec`. */
777
795
  export type StoreEntry = { readonly name: string; readonly codec: FieldCodec }
@@ -798,7 +816,12 @@ export type StoreCore = {
798
816
  setLabels(map: Record<string, Record<string, string> | undefined>): void
799
817
  optionsFor(entry: StoreEntry): readonly FieldOption[]
800
818
  /** The resolution law, once: URL ⊳ localStorage ⊳ fallback. `search: null` skips the URL lane. */
801
- resolve(entry: StoreEntry, search: StoreRecord | null, record: StoreRecord): unknown
819
+ resolve(
820
+ entry: StoreEntry,
821
+ search: StoreRecord | null,
822
+ record: StoreRecord,
823
+ fallback?: unknown,
824
+ ): unknown
802
825
  writeField(setRecord: (next: StoreRecord) => void, name: string, value: unknown): void
803
826
  /**
804
827
  * The IN-MEMORY lane's `use()` for one field — a field with neither the URL nor the mirror.
@@ -841,14 +864,19 @@ function assignDefined(into: StoreRecord, from: StoreRecord): void {
841
864
  * The resolution law, in one place: URL ⊳ localStorage ⊳ fallback (C4). `search: null` skips the
842
865
  * URL lane — what the local lane and `linkSearch` pass.
843
866
  */
844
- function resolveField(entry: StoreEntry, search: StoreRecord | null, record: StoreRecord): unknown {
867
+ function resolveField(
868
+ entry: StoreEntry,
869
+ search: StoreRecord | null,
870
+ record: StoreRecord,
871
+ fallback?: unknown,
872
+ ): unknown {
845
873
  const fromUrl = search === null ? null : entry.codec.fromSearch(search)
846
874
  if (fromUrl !== null) return fromUrl
847
875
  if (entry.codec.lane.persist) {
848
876
  const stored = entry.codec.decode(record[entry.name])
849
877
  if (stored !== null) return stored
850
878
  }
851
- return entry.codec.fallback
879
+ return fallback === undefined ? entry.codec.fallback : fallback
852
880
  }
853
881
 
854
882
  /**
@@ -865,7 +893,7 @@ function resolveField(entry: StoreEntry, search: StoreRecord | null, record: Sto
865
893
  function createMemoryLane(): {
866
894
  subscribe: (cb: () => void) => () => void
867
895
  getVersion: () => number
868
- resolve: (entry: StoreEntry) => unknown
896
+ resolve: (entry: StoreEntry, fallback?: unknown) => unknown
869
897
  write: (name: string, value: unknown) => void
870
898
  remove: (name: string) => void
871
899
  clear: () => void
@@ -889,9 +917,10 @@ function createMemoryLane(): {
889
917
  getVersion: () => version,
890
918
  // `undefined` is the only "never written" marker the lane needs: no field kind has `undefined`
891
919
  // as a legal value, so it can never collide with a written one.
892
- resolve: (entry) => {
920
+ resolve: (entry, fallback) => {
893
921
  const stored = values.get(entry.name)
894
- return stored === undefined ? entry.codec.fallback : stored
922
+ if (stored !== undefined) return stored
923
+ return fallback === undefined ? entry.codec.fallback : fallback
895
924
  },
896
925
  write: (name, value) => {
897
926
  values.set(name, value)
@@ -1036,11 +1065,14 @@ export function createStoreCore(o: StoreCoreOptions): StoreCore {
1036
1065
  },
1037
1066
  readRecord,
1038
1067
  memoryUse(entry) {
1039
- return () => {
1068
+ return (read) => {
1040
1069
  // Subscribing to the VERSION (never to the value) is what re-renders every other mount of
1041
1070
  // this field when one of them writes, with no snapshot-stability trap.
1042
1071
  useSyncExternalStore(memory.subscribe, memory.getVersion, memory.getVersion)
1043
- return [memory.resolve(entry), (next: unknown) => memory.write(entry.name, next)] as const
1072
+ return [
1073
+ memory.resolve(entry, read?.fallback),
1074
+ (next: unknown) => memory.write(entry.name, next),
1075
+ ] as const
1044
1076
  }
1045
1077
  },
1046
1078
  readMemoryValue: (entry) => memory.resolve(entry),
@@ -1074,7 +1106,7 @@ export function createStoreCore(o: StoreCoreOptions): StoreCore {
1074
1106
 
1075
1107
  /** `Pick<SearchStore<S>, 'field' | 'readStored' | 'labels'>` — the same handles, no router. */
1076
1108
  export type LocalStore<S extends Record<string, AnyField>> = {
1077
- readonly field: { [K in keyof S]: FieldHandle<S[K]> }
1109
+ readonly field: { [K in keyof S]: LocalFieldHandle<S[K]> }
1078
1110
  readStored: () => Partial<StoredValues<S>>
1079
1111
  /**
1080
1112
  * Option labels for enum / multi / range fields — the same chainable contract
@@ -1116,20 +1148,24 @@ export function createLocalStore<const S extends Record<string, AnyField>>(o: {
1116
1148
  }): LocalStore<S> {
1117
1149
  const core = createStoreCore({ key: o.key, fields: o.fields, version: o.version })
1118
1150
 
1119
- const persistedUse = (entry: StoreEntry) => (): readonly [unknown, (next: unknown) => void] => {
1120
- const [record, setRecord] = core.usePersistedRecord()
1121
- return [
1122
- core.resolve(entry, null, record),
1123
- (next: unknown) => core.writeField(setRecord, entry.name, next),
1124
- ] as const
1125
- }
1151
+ const persistedUse =
1152
+ (entry: StoreEntry): FieldUse =>
1153
+ (read) => {
1154
+ const [record, setRecord] = core.usePersistedRecord()
1155
+ return [
1156
+ // The call-site fallback is applied here, in render, after the store read — never inside
1157
+ // the snapshot — so React's out-of-render snapshot reads cannot disagree with it.
1158
+ core.resolve(entry, null, record, read?.fallback),
1159
+ (next: unknown) => core.writeField(setRecord, entry.name, next),
1160
+ ] as const
1161
+ }
1126
1162
 
1127
- const field = {} as { [K in keyof S]: FieldHandle<S[K]> }
1163
+ const field = {} as { [K in keyof S]: LocalFieldHandle<S[K]> }
1128
1164
  for (const entry of core.entries) {
1129
1165
  const persisted = entry.codec.lane.persist
1130
1166
  const use = persisted ? persistedUse(entry) : core.memoryUse(entry)
1131
1167
  const clear = persisted ? () => core.clearField(entry.name) : () => core.clearMemory(entry.name)
1132
- field[entry.name as keyof S] = core.handle(entry, use, clear) as unknown as FieldHandle<
1168
+ field[entry.name as keyof S] = core.handle(entry, use, clear) as unknown as LocalFieldHandle<
1133
1169
  S[keyof S]
1134
1170
  >
1135
1171
  }