wenay-react2 1.0.33 → 1.0.35

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.
@@ -0,0 +1,61 @@
1
+ import { tReorderItem } from './useReorder';
2
+ /** useReorderBoard - the columns extension of useReorder: keyed blocks live in
3
+ * VERTICAL columns (plain consumer divs), one block drags between/within them,
4
+ * ONE commit on drop. Column "gravity" is pure consumer CSS (justify-content:
5
+ * flex-start packs up, flex-end packs down) - the hook never knows it, it
6
+ * measures the real layout. Columns can appear/disappear between drags (the
7
+ * ref callback registry is live); the set is frozen for the duration of one
8
+ * drag. Children of a column div must be exactly its items, 1:1, in order.
9
+ * Same non-goals as useReorder: no nesting, no spans/collision packing, no
10
+ * autoscroll - that day is a ready-made dnd library. */
11
+ export type tBoardPos = {
12
+ col: string;
13
+ index: number;
14
+ };
15
+ export type tBoardColumn = {
16
+ key: string;
17
+ items: string[];
18
+ };
19
+ export type tReorderBoardOptions = {
20
+ /** columns in board order; each column's div children must render items 1:1 */
21
+ columns: tBoardColumn[];
22
+ /** ONE commit on drop; skipped when nothing moved */
23
+ commit: (next: tBoardColumn[]) => void;
24
+ /** false = this key cannot start a drag (default: all can) */
25
+ canDrag?: (key: string) => boolean;
26
+ /** hold before the drag starts; default 0 */
27
+ holdMs?: number;
28
+ /** the block was grabbed */
29
+ onDragStart?: (e: {
30
+ key: string;
31
+ from: tBoardPos;
32
+ }) => void;
33
+ /** every pointer move while dragging; position = pointer delta in local px */
34
+ onDragMove?: (e: {
35
+ key: string;
36
+ over: tBoardPos;
37
+ position: {
38
+ x: number;
39
+ y: number;
40
+ };
41
+ }) => void;
42
+ /** the target slot changed (compare prev.col != over.col for column crossings) */
43
+ onOverChange?: (e: {
44
+ key: string;
45
+ over: tBoardPos;
46
+ prev: tBoardPos | null;
47
+ }) => void;
48
+ /** drop; committed=false when nothing moved (plain click) */
49
+ onDragEnd?: (e: {
50
+ key: string;
51
+ from: tBoardPos;
52
+ over: tBoardPos;
53
+ committed: boolean;
54
+ }) => void;
55
+ };
56
+ export declare function useReorderBoard(o: tReorderBoardOptions): {
57
+ columnRef: (col: string) => (el: HTMLElement | null) => void;
58
+ item: (key: string) => tReorderItem;
59
+ dragKey: string | null;
60
+ over: tBoardPos | null;
61
+ };
@@ -0,0 +1,232 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useDraggableApi } from './useDraggable';
3
+ export function useReorderBoard(o) {
4
+ const oRef = useRef(o);
5
+ oRef.current = o;
6
+ const colEls = useRef(new Map());
7
+ const refCbs = useRef(new Map());
8
+ const [dragKey, setDragKey] = useState(null);
9
+ const geom = useRef(null);
10
+ const measureCache = useRef(null);
11
+ /** stable callback ref for a column div; columns can be added at runtime */
12
+ function columnRef(col) {
13
+ let cb = refCbs.current.get(col);
14
+ if (!cb) {
15
+ cb = el => { el ? colEls.current.set(col, el) : colEls.current.delete(col); };
16
+ refCbs.current.set(col, cb);
17
+ }
18
+ return cb;
19
+ }
20
+ const local = (v) => v / (geom.current?.scale ?? 1);
21
+ /** Nearest column by clamped distance to its rect, then the insertion index
22
+ * = how many of that column's items (start centers, excluding the dragged
23
+ * one) sit above the dragged center. Targeting always runs against the
24
+ * geometry measured at drag START (anti-oscillation, same as useReorder). */
25
+ function dragTarget(dx, dy) {
26
+ const g = geom.current;
27
+ if (!g)
28
+ return { col: '', index: 0 };
29
+ const cx = g.draggedCenter.x + dx, cy = g.draggedCenter.y + dy;
30
+ let best = null, bestD = Infinity;
31
+ for (const [col, r] of g.colRect) {
32
+ const px = Math.max(r.x, Math.min(r.x + r.w, cx));
33
+ const py = Math.max(r.y, Math.min(r.y + r.h, cy));
34
+ const d = (px - cx) ** 2 + (py - cy) ** 2;
35
+ if (d < bestD) {
36
+ bestD = d;
37
+ best = col;
38
+ }
39
+ }
40
+ if (best == null)
41
+ return g.from;
42
+ let index = 0;
43
+ for (const c of geom.current.centers.get(best) ?? [])
44
+ if (c.key != dragKey && c.y < cy)
45
+ index++;
46
+ return { col: best, index };
47
+ }
48
+ /** Simulated commit: the dragged key removed from its column and inserted at
49
+ * over - preview and drop share this, so they agree by construction. */
50
+ function movedColumns(key, over) {
51
+ return oRef.current.columns.map(c => {
52
+ let items = c.items.filter(k => k != key);
53
+ if (c.key == over.col) {
54
+ const i = Math.max(0, Math.min(items.length, over.index));
55
+ items = items.slice(0, i).concat(key, items.slice(i));
56
+ }
57
+ return { key: c.key, items };
58
+ });
59
+ }
60
+ /** FLIP across columns, all offset-based (transform/transition-immune, see
61
+ * useReorder): the dragged block leaves flow via display:none, survivors get
62
+ * their preview CSS `order`, and the landing slot becomes a real margin gap
63
+ * (draggedHeight + row-gap) on the neighbour - so the column's own CSS
64
+ * gravity decides who moves aside (flex-end pushes the blocks ABOVE up).
65
+ * One synchronous apply-read-revert, cached per target slot. */
66
+ function measured(key, over) {
67
+ const cacheKey = over.col + '#' + over.index;
68
+ if (measureCache.current?.key == cacheKey)
69
+ return measureCache.current.pos;
70
+ const g = geom.current;
71
+ const keyToEl = new Map();
72
+ for (const c of oRef.current.columns) {
73
+ const el = colEls.current.get(c.key);
74
+ if (!el)
75
+ continue;
76
+ const kids = el.children;
77
+ c.items.forEach((k, i) => { const kid = kids[i]; if (kid)
78
+ keyToEl.set(k, kid); });
79
+ }
80
+ const preview = movedColumns(key, over);
81
+ const saved = [];
82
+ const save = (el) => saved.push({ el, order: el.style.order, display: el.style.display, mt: el.style.marginTop, mb: el.style.marginBottom });
83
+ for (const pc of preview) {
84
+ pc.items.forEach((k, i) => {
85
+ const el = keyToEl.get(k);
86
+ if (!el)
87
+ return;
88
+ save(el);
89
+ if (k == key)
90
+ el.style.display = 'none';
91
+ else
92
+ el.style.order = String(i);
93
+ });
94
+ }
95
+ const target = preview.find(c => c.key == over.col);
96
+ if (target) {
97
+ const gap = g.draggedSize.h + (g.rowGap.get(over.col) ?? 0);
98
+ const afterEl = keyToEl.get(target.items[over.index + 1] ?? '');
99
+ const beforeEl = keyToEl.get(target.items[over.index - 1] ?? '');
100
+ if (afterEl)
101
+ afterEl.style.marginTop = gap + 'px';
102
+ else if (beforeEl)
103
+ beforeEl.style.marginBottom = gap + 'px';
104
+ }
105
+ const pos = new Map();
106
+ for (const c of oRef.current.columns)
107
+ for (const k of c.items) {
108
+ if (k == key)
109
+ continue;
110
+ const el = keyToEl.get(k);
111
+ if (el)
112
+ pos.set(k, { x: el.offsetLeft, y: el.offsetTop });
113
+ }
114
+ saved.reverse().forEach(s => { s.el.style.order = s.order; s.el.style.display = s.display; s.el.style.marginTop = s.mt; s.el.style.marginBottom = s.mb; });
115
+ measureCache.current = { key: cacheKey, pos };
116
+ return pos;
117
+ }
118
+ const drag = useDraggableApi({ holdMs: o.holdMs ?? 0, onDragEnd: function commitBoard(final) {
119
+ const key = dragKey;
120
+ setDragKey(null);
121
+ measureCache.current = null;
122
+ const g = geom.current;
123
+ geom.current = null;
124
+ if (key == null || !g)
125
+ return;
126
+ geom.current = g; // dragTarget/local need it for this last computation
127
+ const over = dragTarget(local(final.x), local(final.y));
128
+ const next = movedColumns(key, over);
129
+ const cur = oRef.current.columns;
130
+ const committed = next.some((c, i) => c.items.length != cur[i].items.length || c.items.some((k, j) => k != cur[i].items[j]));
131
+ geom.current = null;
132
+ if (committed)
133
+ oRef.current.commit(next);
134
+ oRef.current.onDragEnd?.({ key, from: g.from, over, committed });
135
+ } });
136
+ function beginDrag(key, e) {
137
+ if (oRef.current.canDrag && !oRef.current.canDrag(key))
138
+ return false;
139
+ if (e.target.closest('input, button, select, textarea, a'))
140
+ return false;
141
+ const cols = oRef.current.columns;
142
+ let from = null;
143
+ for (const c of cols) {
144
+ const i = c.items.indexOf(key);
145
+ if (i != -1) {
146
+ from = { col: c.key, index: i };
147
+ break;
148
+ }
149
+ }
150
+ const fromEl = from && colEls.current.get(from.col);
151
+ if (!from || !fromEl)
152
+ return false;
153
+ const scale = fromEl.offsetWidth ? fromEl.getBoundingClientRect().width / fromEl.offsetWidth : 1;
154
+ const g = { scale, from, colRect: new Map(), centers: new Map(), startOffset: new Map(), rowGap: new Map(), draggedCenter: { x: 0, y: 0 }, draggedSize: { w: 0, h: 0 } };
155
+ for (const c of cols) {
156
+ const el = colEls.current.get(c.key);
157
+ if (!el)
158
+ continue;
159
+ const r = el.getBoundingClientRect();
160
+ g.colRect.set(c.key, { x: r.x / scale, y: r.y / scale, w: r.width / scale, h: r.height / scale });
161
+ g.rowGap.set(c.key, parseFloat(getComputedStyle(el).rowGap) || 0);
162
+ const kids = el.children;
163
+ const cs = [];
164
+ c.items.forEach((k, i) => {
165
+ const kid = kids[i];
166
+ if (!kid)
167
+ return;
168
+ const kr = kid.getBoundingClientRect();
169
+ cs.push({ key: k, y: (kr.y + kr.height / 2) / scale });
170
+ g.startOffset.set(k, { x: kid.offsetLeft, y: kid.offsetTop });
171
+ if (k == key) {
172
+ g.draggedCenter = { x: (kr.x + kr.width / 2) / scale, y: (kr.y + kr.height / 2) / scale };
173
+ g.draggedSize = { w: kid.offsetWidth, h: kid.offsetHeight };
174
+ }
175
+ });
176
+ g.centers.set(c.key, cs);
177
+ }
178
+ geom.current = g;
179
+ measureCache.current = null;
180
+ setDragKey(key);
181
+ oRef.current.onDragStart?.({ key, from });
182
+ return true;
183
+ }
184
+ const over = dragKey != null && geom.current
185
+ ? dragTarget(local(drag.position.x), local(drag.position.y)) : null;
186
+ // callbacks ride effects (post-render), reading fresh options via oRef
187
+ const prevOverRef = useRef(null);
188
+ useEffect(() => {
189
+ if (dragKey == null || !over) {
190
+ prevOverRef.current = null;
191
+ return;
192
+ }
193
+ const prev = prevOverRef.current;
194
+ if (!prev || prev.col != over.col || prev.index != over.index) {
195
+ oRef.current.onOverChange?.({ key: dragKey, over, prev });
196
+ prevOverRef.current = { ...over };
197
+ }
198
+ });
199
+ const px = dragKey != null ? drag.position.x : 0, py = dragKey != null ? drag.position.y : 0;
200
+ useEffect(() => {
201
+ if (dragKey != null && over)
202
+ oRef.current.onDragMove?.({ key: dragKey, over, position: { x: px, y: py } });
203
+ }, [dragKey, px, py]); // eslint-disable-line react-hooks/exhaustive-deps
204
+ function item(key) {
205
+ const active = dragKey != null;
206
+ const dragging = key == dragKey;
207
+ let style;
208
+ if (active && geom.current && over) {
209
+ if (dragging) {
210
+ style = { transform: `translate(${local(drag.position.x)}px, ${local(drag.position.y)}px)` };
211
+ }
212
+ else {
213
+ const pos = measured(dragKey, over);
214
+ const a = geom.current.startOffset.get(key), b = pos.get(key);
215
+ if (a && b && (a.x != b.x || a.y != b.y))
216
+ style = { transform: `translate(${b.x - a.x}px, ${b.y - a.y}px)` };
217
+ }
218
+ }
219
+ return {
220
+ props: {
221
+ onMouseDown(e) { if (e.button == 0 && beginDrag(key, e))
222
+ drag.props.onMouseDown(e); },
223
+ onTouchStart(e) { if (beginDrag(key, e))
224
+ drag.props.onTouchStart(e); },
225
+ },
226
+ style,
227
+ dragging,
228
+ active,
229
+ };
230
+ }
231
+ return { columnRef, item, dragKey, over };
232
+ }
@@ -1,4 +1,6 @@
1
1
  import { ObserveAll2, Replay } from "wenay-common2";
2
+ type StoreDrain = ObserveAll2.StoreDrain;
3
+ type StoreEachCtx = ObserveAll2.StoreEachCtx;
2
4
  type StorePatch = ObserveAll2.StorePatch;
3
5
  type ReplayEvent<Z extends any[]> = Replay.ReplayEvent<Z>;
4
6
  type ReplayRemote<Z extends any[]> = Replay.ReplayRemote<Z>;
@@ -21,6 +23,20 @@ export type UseReplaySubscribeOptions = {
21
23
  staleMs?: number;
22
24
  /** Edge-triggered mirror of common2's onStale (fresh<->stale transitions only). Goes through a ref — a new identity does not resubscribe. */
23
25
  onStale?: (info: StaleInfo) => void;
26
+ /**
27
+ * Lag policy of the wire subscription (frame model): 'queue' (default) — the socket buffers
28
+ * everything, nothing is ever skipped; 'frame' — rides the server's frameLine when the remote
29
+ * has one: on lag the server DROPS events for this client and recovers with a mini-frame.
30
+ * Without a frameLine (old server, in-proc line) 'frame' degrades to 'queue' in common2.
31
+ * This picks the wire surface, so changing it resubscribes (reconnects by tail under keepSeq).
32
+ */
33
+ policy?: 'queue' | 'frame';
34
+ /**
35
+ * Opaque pass-through for the line's `frame` condenser on catch-up (which condensation rule
36
+ * this client wants). Captured at subscribe time through a ref — a new identity does not
37
+ * resubscribe; the next (re)subscribe reads the latest value.
38
+ */
39
+ hint?: unknown;
24
40
  };
25
41
  export type ReplaySubscribeController = {
26
42
  /** Keyframe/tail catch-up finished, events are live from here. */
@@ -64,6 +80,80 @@ export type StoreReplayMirrorController<T extends object> = StoreReplaySyncContr
64
80
  * so reconnect is a journal tail, not a keyframe. A new `remote` recreates the store.
65
81
  */
66
82
  export declare function useStoreReplayMirror<T extends object>(remote: ReplayRemote<[StorePatch]> | null | undefined, initial: T, options?: UseStoreReplaySyncOptions): StoreReplayMirrorController<T>;
83
+ export type UseStoreReplayEachOptions<T extends object> = UseStoreReplaySyncOptions & {
84
+ /**
85
+ * Seed of the internal mirror store. Creation-time only (a later identity change does nothing).
86
+ * Reconnect after a FULL unmount: pass the saved snapshot together with `since`
87
+ * ({since: prev.seq(), initial: prev.store.snapshot()}) — the tail lands ON TOP of the previous
88
+ * state; a fresh empty mirror would not converge.
89
+ */
90
+ initial?: T;
91
+ /** Drain of the internal mirror store — the coalescing window of the per-key feed. Creation-time only. */
92
+ drain?: StoreDrain;
93
+ };
94
+ /**
95
+ * Per-key fold over a store replay line — React counterpart of `ObserveAll2.syncStoreReplayEach`
96
+ * (internal mirror store + syncStoreReplay + store.each()). cb fires once per CHANGED top-level
97
+ * key per drain window with the current value; the first delivery is the keyframe EXPANDED per
98
+ * key; (key, undefined) = key deleted — cold start / reconnect are not special cases for per-key
99
+ * consumers (grid rows, canvas layers, ...). The fold target should live outside React state.
100
+ *
101
+ * Unlike the library one-call (a fresh store per call), the mirror here lives in a ref: within
102
+ * one mounted component every resubscribe (StrictMode double-effect, restart(), enabled toggling,
103
+ * staleMs/policy change) reconnects by tail ON TOP of the kept state — no snapshot/initial dance.
104
+ * A new `remote` identity recreates the store (fresh keyframe). Direct reads / extra
105
+ * subscriptions: controller.store (useStoreNode/useStoreKeys work on it as usual).
106
+ */
107
+ export declare function useStoreReplayEach<T extends object>(remote: ReplayRemote<[StorePatch]> | null | undefined, cb: (key: string, value: T[keyof T] | undefined, ctx: StoreEachCtx) => void, options?: UseStoreReplayEachOptions<T>): StoreReplayMirrorController<T>;
108
+ export type UseReplayFrameOptions = {
109
+ /** Pull period, ms. Default 300. */
110
+ intervalMs?: number;
111
+ /**
112
+ * Start position: frame() brings the consumer from here to head. Omit = keyframe start:
113
+ * keyframe() is polled until the line has one (an empty line is "nothing yet", not an error).
114
+ * A sacred line (no keyframe) NEEDS an explicit since (0 = full tail) — omitted, it waits forever.
115
+ */
116
+ since?: number;
117
+ /** Keep the last folded seq across remote-identity-stable resubscribes. Default true. */
118
+ keepSeq?: boolean;
119
+ /** false = stop pulling (and drop the timer). Default true. */
120
+ enabled?: boolean;
121
+ /**
122
+ * Opaque pass-through for the line's `frame` condenser (which condensation rule this client
123
+ * wants). Read through a ref on EVERY pull — a new identity neither resubscribes nor is missed.
124
+ */
125
+ hint?: unknown;
126
+ onSeq?: (seq: number) => void;
127
+ /** frame() failed (network, or a sacred line evicted past our seq — loud by design). Pulling STOPS until restart(). */
128
+ onError?: (e: unknown) => void;
129
+ };
130
+ export type ReplayFrameController = {
131
+ /** First successful pull finished (like replaySubscribe's ready — even if the line was still empty). */
132
+ readonly ready: boolean;
133
+ /** Last pull error; pulling is stopped while set (restart() clears and re-arms). */
134
+ readonly error: unknown;
135
+ /** Last folded seq (reconnect point). Getter — reading it does not re-render. */
136
+ seq(): number;
137
+ /** Pull out of schedule now; resolves after folding. hint omitted = the latest options.hint. */
138
+ pull(hint?: unknown): Promise<void>;
139
+ /** Re-arm after an error / jump: since omitted = continue from the current seq. */
140
+ restart(since?: number): void;
141
+ };
142
+ /**
143
+ * Pull a replay line at YOUR pace (the frame model of common2): a timer around
144
+ * `remote.frame(seq, hint)` — the server condenses the tail via the line's `frame` lambda
145
+ * (mini-frame), so a slow consumer never accumulates a backlog and never holds a live socket
146
+ * subscription. Complements useReplaySubscribe (push): use pull when the consumer wants its own
147
+ * cadence (e.g. 500ms UI refresh over a fast line) or a client-picked condensation rule (hint).
148
+ *
149
+ * Folding contract mirrors the push path: envelopes arrive seq-ascending, already-seen seq are
150
+ * skipped, a keyframe recovery is just an envelope of the same event type. Fresh start (no since)
151
+ * = keyframe start like replaySubscribe: keyframe() is polled until the line has one; a sacred
152
+ * line needs an explicit since. Overlapping pulls are never issued (a slow frame() call skips
153
+ * timer ticks). A remote without frame() (old server) fails loudly via onError — there is
154
+ * deliberately no tail fallback here (that is replaySubscribe's job).
155
+ */
156
+ export declare function useReplayFrame<Z extends any[]>(remote: ReplayRemote<Z> | null | undefined, cb: (...event: Z) => void, options?: UseReplayFrameOptions): ReplayFrameController;
67
157
  export type ReplayHistoryLike<Z extends any[]> = {
68
158
  at(where?: {
69
159
  seq?: number;
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { ObserveAll2, Replay } from "wenay-common2";
3
+ import { useStoreEach } from "./useObserveStore";
3
4
  /**
4
5
  * React bridge over the Replay stack (snapshot + sequenced delta line) of wenay-common2.
5
6
  *
@@ -13,10 +14,14 @@ import { ObserveAll2, Replay } from "wenay-common2";
13
14
  * outside via onSeq and pass it back as `since` (see the QA card for the pattern);
14
15
  * - a different `remote` identity always starts from scratch (seq from another line is meaningless);
15
16
  * - freshness (staleMs/onStale, controller.stale/lastTs()) is detected by wenay-common2;
16
- * the hooks only mirror the fresh<->stale edges into React state.
17
+ * the hooks only mirror the fresh<->stale edges into React state;
18
+ * - lag policy is the consumer's pick per subscription (frame model of common2 rev2):
19
+ * {policy: 'frame'} rides the server's frameLine when the remote has one (on lag the server
20
+ * drops and recovers with a mini-frame; old servers/in-proc lines degrade to 'queue');
21
+ * pull-at-own-pace is a separate hook (useReplayFrame) — a timer around remote.frame().
17
22
  *
18
- * Server-side parts of the stack (conflateReplay, archiveReplay) are per-connection/per-process
19
- * and intentionally have no hooks here.
23
+ * Server-side parts of the stack (conflateReplay, archiveReplay, createRpcServerAuto replayOpts)
24
+ * are per-connection/per-process and intentionally have no hooks here.
20
25
  */
21
26
  function useLatestRef(value) {
22
27
  const ref = useRef(value);
@@ -30,9 +35,10 @@ function useLatestRef(value) {
30
35
  * cb goes through a ref — a new cb identity does not resubscribe.
31
36
  */
32
37
  export function useReplaySubscribe(remote, cb, options = {}) {
33
- const { since, keepSeq = true, enabled = true, onSeq, onError, staleMs, onStale } = options;
38
+ const { since, keepSeq = true, enabled = true, onSeq, onError, staleMs, onStale, policy, hint } = options;
34
39
  const cbRef = useLatestRef(cb);
35
40
  const hooksRef = useLatestRef({ onSeq, onError, onStale });
41
+ const hintRef = useLatestRef(hint);
36
42
  const seqRef = useRef(since);
37
43
  const subRef = useRef(null);
38
44
  const lastRemoteRef = useRef(undefined);
@@ -55,6 +61,8 @@ export function useReplaySubscribe(remote, cb, options = {}) {
55
61
  setStale(false);
56
62
  const off = Replay.replaySubscribe(remote, (...event) => cbRef.current(...event), {
57
63
  since: seqRef.current,
64
+ policy,
65
+ hint: hintRef.current,
58
66
  onSeq: seq => {
59
67
  seqRef.current = seq;
60
68
  hooksRef.current.onSeq?.(seq);
@@ -90,9 +98,10 @@ export function useReplaySubscribe(remote, cb, options = {}) {
90
98
  seqRef.current = since;
91
99
  };
92
100
  // keepSeq/since are start-position config, not subscription identity — no resubscribe on change;
93
- // staleMs is subscribe-time config in common2, so changing it resubscribes
101
+ // staleMs is subscribe-time config in common2, so changing it resubscribes;
102
+ // policy picks the wire surface (line vs frameLine), so it resubscribes too; hint rides a ref
94
103
  // eslint-disable-next-line react-hooks/exhaustive-deps
95
- }, [remote, enabled, epoch, staleMs]);
104
+ }, [remote, enabled, epoch, staleMs, policy]);
96
105
  const seq = useCallback(() => subRef.current?.seq() ?? seqRef.current ?? -1, []);
97
106
  const lastTs = useCallback(() => subRef.current?.lastTs() ?? 0, []);
98
107
  const restart = useCallback((at) => {
@@ -109,8 +118,9 @@ export function useReplaySubscribe(remote, cb, options = {}) {
109
118
  * (useStoreNode/useStoreSelect).
110
119
  */
111
120
  export function useStoreReplaySync(store, remote, options = {}) {
112
- const { since, keepSeq = true, enabled = true, onSeq, onError, staleMs, onStale } = options;
121
+ const { since, keepSeq = true, enabled = true, onSeq, onError, staleMs, onStale, policy, hint } = options;
113
122
  const hooksRef = useLatestRef({ onSeq, onError, onStale });
123
+ const hintRef = useLatestRef(hint);
114
124
  const seqRef = useRef(since);
115
125
  const subRef = useRef(null);
116
126
  const lastRemoteRef = useRef(undefined);
@@ -131,6 +141,8 @@ export function useStoreReplaySync(store, remote, options = {}) {
131
141
  setStale(false); // see useReplaySubscribe: stale re-syncs from common2, no reset-to-false flicker
132
142
  const off = ObserveAll2.syncStoreReplay(store, remote, {
133
143
  since: seqRef.current,
144
+ policy,
145
+ hint: hintRef.current,
134
146
  onSeq: seq => {
135
147
  seqRef.current = seq;
136
148
  hooksRef.current.onSeq?.(seq);
@@ -166,7 +178,7 @@ export function useStoreReplaySync(store, remote, options = {}) {
166
178
  seqRef.current = since;
167
179
  };
168
180
  // eslint-disable-next-line react-hooks/exhaustive-deps
169
- }, [store, remote, enabled, epoch, staleMs]);
181
+ }, [store, remote, enabled, epoch, staleMs, policy]);
170
182
  const seq = useCallback(() => subRef.current?.seq() ?? seqRef.current ?? -1, []);
171
183
  const lastTs = useCallback(() => subRef.current?.lastTs() ?? 0, []);
172
184
  const restart = useCallback((at) => {
@@ -190,6 +202,141 @@ export function useStoreReplayMirror(remote, initial, options = {}) {
190
202
  const sync = useStoreReplaySync(store, remote, options);
191
203
  return useMemo(() => ({ ...sync, store }), [sync, store]);
192
204
  }
205
+ /**
206
+ * Per-key fold over a store replay line — React counterpart of `ObserveAll2.syncStoreReplayEach`
207
+ * (internal mirror store + syncStoreReplay + store.each()). cb fires once per CHANGED top-level
208
+ * key per drain window with the current value; the first delivery is the keyframe EXPANDED per
209
+ * key; (key, undefined) = key deleted — cold start / reconnect are not special cases for per-key
210
+ * consumers (grid rows, canvas layers, ...). The fold target should live outside React state.
211
+ *
212
+ * Unlike the library one-call (a fresh store per call), the mirror here lives in a ref: within
213
+ * one mounted component every resubscribe (StrictMode double-effect, restart(), enabled toggling,
214
+ * staleMs/policy change) reconnects by tail ON TOP of the kept state — no snapshot/initial dance.
215
+ * A new `remote` identity recreates the store (fresh keyframe). Direct reads / extra
216
+ * subscriptions: controller.store (useStoreNode/useStoreKeys work on it as usual).
217
+ */
218
+ export function useStoreReplayEach(remote, cb, options = {}) {
219
+ const { initial, drain, ...syncOptions } = options;
220
+ const storeRef = useRef(null);
221
+ if (!storeRef.current || storeRef.current.remote !== remote) {
222
+ storeRef.current = { remote, store: ObserveAll2.createStore((initial ?? {}), drain !== undefined ? { drain } : undefined) };
223
+ }
224
+ const store = storeRef.current.store;
225
+ // each BEFORE sync: effects run in hook-call order, so the per-key subscriber already exists
226
+ // when the keyframe applies to the store (the expansion is not missed)
227
+ useStoreEach(store, cb, { enabled: syncOptions.enabled });
228
+ const sync = useStoreReplaySync(store, remote, syncOptions);
229
+ return useMemo(() => ({ ...sync, store }), [sync, store]);
230
+ }
231
+ /**
232
+ * Pull a replay line at YOUR pace (the frame model of common2): a timer around
233
+ * `remote.frame(seq, hint)` — the server condenses the tail via the line's `frame` lambda
234
+ * (mini-frame), so a slow consumer never accumulates a backlog and never holds a live socket
235
+ * subscription. Complements useReplaySubscribe (push): use pull when the consumer wants its own
236
+ * cadence (e.g. 500ms UI refresh over a fast line) or a client-picked condensation rule (hint).
237
+ *
238
+ * Folding contract mirrors the push path: envelopes arrive seq-ascending, already-seen seq are
239
+ * skipped, a keyframe recovery is just an envelope of the same event type. Fresh start (no since)
240
+ * = keyframe start like replaySubscribe: keyframe() is polled until the line has one; a sacred
241
+ * line needs an explicit since. Overlapping pulls are never issued (a slow frame() call skips
242
+ * timer ticks). A remote without frame() (old server) fails loudly via onError — there is
243
+ * deliberately no tail fallback here (that is replaySubscribe's job).
244
+ */
245
+ export function useReplayFrame(remote, cb, options = {}) {
246
+ const { intervalMs = 300, since, keepSeq = true, enabled = true, hint, onSeq, onError } = options;
247
+ const cbRef = useLatestRef(cb);
248
+ const hooksRef = useLatestRef({ onSeq, onError });
249
+ const hintRef = useLatestRef(hint);
250
+ const seqRef = useRef(since ?? -1);
251
+ const pullRef = useRef(null);
252
+ const lastRemoteRef = useRef(undefined);
253
+ const [ready, setReady] = useState(false);
254
+ const [error, setError] = useState(null);
255
+ const [epoch, setEpoch] = useState(0);
256
+ useEffect(() => {
257
+ if (!remote || !enabled)
258
+ return;
259
+ if (lastRemoteRef.current !== undefined && lastRemoteRef.current !== remote)
260
+ seqRef.current = since ?? -1; // a different line — old seq is meaningless
261
+ lastRemoteRef.current = remote;
262
+ let alive = true;
263
+ let stopped = false;
264
+ let timer = null;
265
+ let inflight = null;
266
+ setReady(false);
267
+ setError(null);
268
+ const fail = (e) => {
269
+ stopped = true;
270
+ if (timer) {
271
+ clearInterval(timer);
272
+ timer = null;
273
+ }
274
+ setError(e);
275
+ hooksRef.current.onError?.(e);
276
+ };
277
+ const pull = (hintArg) => {
278
+ if (inflight)
279
+ return inflight; // a slow frame() skips ticks, pulls never overlap
280
+ if (stopped || !alive)
281
+ return Promise.resolve();
282
+ const frame = remote.frame;
283
+ if (!frame) {
284
+ fail(new Error("useReplayFrame: remote has no frame() (old server) — use useReplaySubscribe"));
285
+ return Promise.resolve();
286
+ }
287
+ inflight = (async () => {
288
+ try {
289
+ // no position yet -> keyframe start, mirroring replaySubscribe's catch-up for since<0:
290
+ // frame(-1) has no tail to return and THROWS on a still-empty line, which is not an
291
+ // error here — poll keyframe() until the line has one, then switch to frame(seq)
292
+ const envs = seqRef.current < 0
293
+ ? await Promise.resolve(remote.keyframe()).then(kf => kf ? [kf] : null)
294
+ : await frame(seqRef.current, hintArg !== undefined ? hintArg : hintRef.current);
295
+ if (!alive)
296
+ return;
297
+ for (const ev of envs ?? []) {
298
+ if (ev.seq <= seqRef.current)
299
+ continue;
300
+ seqRef.current = ev.seq;
301
+ cbRef.current(...ev.event);
302
+ hooksRef.current.onSeq?.(ev.seq);
303
+ }
304
+ setReady(true);
305
+ }
306
+ catch (e) {
307
+ if (alive)
308
+ fail(e);
309
+ }
310
+ finally {
311
+ inflight = null;
312
+ }
313
+ })();
314
+ return inflight;
315
+ };
316
+ pullRef.current = pull;
317
+ void pull();
318
+ if (!stopped)
319
+ timer = setInterval(() => { void pull(); }, intervalMs); // a sync throw in the first pull may already have stopped us
320
+ return () => {
321
+ alive = false;
322
+ pullRef.current = null;
323
+ if (timer)
324
+ clearInterval(timer);
325
+ if (!keepSeq)
326
+ seqRef.current = since ?? -1;
327
+ };
328
+ // since/keepSeq are start-position config, hint rides a ref — only the pace and identity resubscribe
329
+ // eslint-disable-next-line react-hooks/exhaustive-deps
330
+ }, [remote, enabled, epoch, intervalMs]);
331
+ const seq = useCallback(() => seqRef.current, []);
332
+ const pull = useCallback((hintArg) => pullRef.current?.(hintArg) ?? Promise.resolve(), []);
333
+ const restart = useCallback((at) => {
334
+ if (at !== undefined)
335
+ seqRef.current = at;
336
+ setEpoch(v => v + 1);
337
+ }, []);
338
+ return useMemo(() => ({ ready, error, seq, pull, restart }), [ready, error, seq, pull, restart]);
339
+ }
193
340
  /**
194
341
  * Time machine over `Replay.openHistory(storage, live?)`: scrubber/pause/resume for any replay line.
195
342
  * `apply` folds ONE event into the consumer state (draw a frame, apply a store patch, ...) —
@@ -57,6 +57,23 @@ export declare const tokens: {
57
57
  readonly navBg: "#17202e";
58
58
  readonly navWidth: "168px";
59
59
  };
60
+ /** Toolbar (createToolbar) chrome (--tb-*). Dark defaults; apps re-skin via :root[data-theme]. */
61
+ readonly tb: {
62
+ readonly bg: "transparent";
63
+ readonly border: "none";
64
+ readonly radius: "6px";
65
+ readonly gap: "2px";
66
+ /** var(--color-text-base) in CSS */
67
+ readonly itemColor: "#c4c4c4";
68
+ readonly itemHoverBg: "rgba(255, 255, 255, 0.08)";
69
+ readonly itemRadius: "6px";
70
+ /** var(--color-bg-dark) in CSS */
71
+ readonly popBg: "#131821";
72
+ /** var(--color-border-common) in CSS */
73
+ readonly popBorder: "1px solid rgb(50, 62, 71)";
74
+ readonly popRadius: "8px";
75
+ readonly popShadow: "0 8px 28px rgba(0, 0, 0, 0.5)";
76
+ };
60
77
  readonly font: {
61
78
  readonly family: "Roboto";
62
79
  readonly sizeBase: "12px";