wenay-react2 1.0.40 → 1.0.41

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,475 @@
1
+ # wenay-react2 - BRIEF cheat sheet (canonical UI/controller API)
2
+
3
+ > Root import: `import { ... } from "wenay-react2"`.
4
+ > Notation: `name(args) -> ret`. JSX examples show the intended public path, not every prop.
5
+ > Short controller-style names are canonical. Removed names are recorded in
6
+ > **WENAY_REACT2_RENAMES.md** for migration only; old aliases are not exported.
7
+
8
+ ## Standard
9
+ ```
10
+ Hooks that own lifecycle/state return a small controller:
11
+ api.props / api.bind // props to spread on an element
12
+ api.open() / close() / set() // modal/menu-like state
13
+ api.update() / remove() / clean() // data buffers
14
+ api.fit() / flush() / sync() // ag-grid lifecycle
15
+
16
+ Components receive controllers when state must outlive the component.
17
+ Library primitives never know app-specific terms, domain objects, or group policy.
18
+ Put app-specific layout/build rules in an app wrapper above the primitive.
19
+ ```
20
+
21
+ ## Render Memory
22
+ ```
23
+ updateBy(obj) / useUpdateBy(obj) // subscribe current component to renderBy(obj)
24
+ renderBy(obj, ms?) // emit render for subscribers
25
+ renderByRevers(obj, ms?, reverse?=true)
26
+ renderByLast(obj, ms?)
27
+
28
+ const state = { count: 0 }
29
+ const api = createUpdateApi(state)
30
+ api.use() // hook subscription
31
+ api.emit(ms?) // renderBy(state)
32
+ api.on(obj => {}) -> off
33
+
34
+ const api2 = useUpdateByApi(state) // hook + controller in one call
35
+ ```
36
+
37
+ Persistent process memory:
38
+ ```
39
+ memoryGetOrCreate(key, def, {abs?, deepAutoMerge?, reversDeep?}) -> def-or-stored
40
+ memoryGetById(key, def, id) -> stored only while id is the same
41
+ memorySet(key, data)
42
+ memoryGet(key)
43
+ memoryUpdate(key, mutate) -> cur? // mutate + rerender + announce in one call
44
+ memoryMarkDirty(key) // announce an in-place mutation of a memoryGetOrCreate object
45
+ createSearchHistory({key, max?}) // reusable small persisted search history controller
46
+ memoryMaps // rnd / resize / other maps
47
+ ```
48
+
49
+ Persistence contract (memoryCache): the library NEVER writes storage by itself. The persisted maps
50
+ (floatingWindowMap, mapResiReact, mapRightMenu, memoryProps) are `ObservableMap`s - set/delete/clear
51
+ announce themselves, in-place mutations are announced at the commit points (drag/resize stop,
52
+ menu drag end, setPlace). memoryCache observes the maps it owns; the app owns the write policy:
53
+ ```
54
+ memoryCache.load() // once on start; remembers the saved snapshot
55
+ memoryCache.onDirty((scope?, key?) => ...) -> off // dirty channel (coalesced, async)
56
+ memoryCache.saveDebounced(ms?) / save() / flush() // write only payloads that differ from the snapshot
57
+ memoryCache.isDirty() // cheap hint, e.g. a beforeunload guard
58
+
59
+ memoryCache.load()
60
+ const off = memoryCache.onDirty(() => memoryCache.saveDebounced(800))
61
+ document.addEventListener("visibilitychange",
62
+ () => { if (document.visibilityState == "hidden") void memoryCache.flush() })
63
+ window.addEventListener("pagehide", () => { void memoryCache.flush() }) // backstop: flush is async
64
+ ```
65
+
66
+ ## Outside Click / Buttons
67
+ ```
68
+ const outside = useOutside({onOutside, enabled?})
69
+ <div {...outside.props} />
70
+ outside.enable()
71
+ outside.disable()
72
+ outside.contains(event.target)
73
+
74
+ <OutsideClickArea outsideClick={close} status={open}>...</OutsideClickArea>
75
+
76
+ <Button button={<button>Open</button>} outClick>{...}</Button>
77
+ <OutsideButton button={...}>{...}</OutsideButton>
78
+ <HoverButton button={...}>{...}</HoverButton>
79
+ <AbsoluteButton button={...}>{...}</AbsoluteButton>
80
+ ```
81
+
82
+ ## Drag / Floating Windows
83
+ ```
84
+ const drag = useDraggableApi({initialPosition, holdMs?, onDragStart?, onDragEnd?})
85
+ <div {...drag.bind} style={{transform: `translate(${drag.position.x}px, ${drag.position.y}px)`}} />
86
+ drag.setPosition({x, y})
87
+ drag.resetPosition()
88
+ drag.cancelDrag()
89
+
90
+ const r = useReorder({order, commit, move?, canDrag?, preview?, holdMs?}) // mini reorder-by-drag
91
+ <div ref={r.listRef}>{order.map(k => { // children 1:1 with order
92
+ const it = r.item(k) // it.dragging / it.active
93
+ return <div key={k} {...it.props} style={{...own, ...it.style}} /> // ONE commit(next) on drop
94
+ })}</div>
95
+
96
+ const b = useReorderBoard({columns: [{key, items}], commit, // drag between vertical columns
97
+ canDrag?, holdMs?, onDragStart?, onDragMove?, onOverChange?, onDragEnd?})
98
+ <div ref={b.columnRef('todo')}>{items.map(k => ...b.item(k)...)}</div> // one div per column, any count
99
+ b.over // {col, index} | null - live target
100
+ // column gravity is YOUR CSS: justify-content flex-start packs up, flex-end packs down
101
+
102
+ <FloatingWindow keyForSave="tool" size={{width: 320, height: 240}} header={<div>Tool</div>}>
103
+ <Panel />
104
+ </FloatingWindow>
105
+
106
+ const wnd = useFloatingWindowController({keyForSave: "custom-tool", size: {width: 320, height: 240}})
107
+ wnd.position // {x,y}; bind wnd.onHeaderMouseDown/onHeaderTouchStart for custom chrome
108
+ ```
109
+
110
+ ## Modal / Input
111
+ ```
112
+ <ModalProvider>
113
+ <App />
114
+ </ModalProvider>
115
+
116
+ const modal = useModal()
117
+ modal.open(<Dialog />)
118
+ modal.set(<Dialog />)
119
+ modal.close()
120
+ modal(null) // callable shortcut for clearing
121
+ ```
122
+
123
+ Input helpers:
124
+ ```
125
+ const text = useTextInputPanel({callback: txt => {}, txt: ""}) // headless value + submit API
126
+ const file = useFileInputPanel({callback: file => {}}) // headless file + submit API
127
+ <TextInputPanel callback={txt => {}} name="Name" txt="" /> // thin visual wrapper over hook
128
+ <FileInputPanel callback={file => {}} name="File" /> // thin visual wrapper over hook
129
+ <TextInputModal callback={...} outClick={modal.close} />
130
+ <FileInputModal callback={...} outClick={modal.close} />
131
+ <FreeModal outClick={modal.close} size={{width: 400, height: 260}}>...</FreeModal>
132
+ ```
133
+
134
+ ## Settings Dialog
135
+ ```
136
+ <SettingsDialog trigger={<span>settings</span>} sections={[{key, name, render, children?, parentKey?, searchText?, keywords?}]} defaultSection? /> // searchable tree + persisted search history; history closes when search focus leaves
137
+ const settings = useSettingsDialogController({sections, defaultSection?}) // open/search/tree/history/resize actions for custom chrome
138
+ registerSettingsSection({key, name, render, parentKey?, searchText?, keywords?}) -> unregister
139
+ ```
140
+
141
+ ## UI Slot
142
+ ```
143
+ const slot = createUiSlot({key, places: {top: "Top bar", side: "Sidebar"}, def: "top"})
144
+
145
+ <slot.Slot place="top">{content}</slot.Slot> // each mount point decides by itself
146
+ <slot.PlacementSetting /> // segmented place switcher; setPlace marks memoryCache dirty
147
+ ```
148
+
149
+ ## Toolbar
150
+ ```
151
+ const tb = createToolbar({key, items: [{key, title, icon?, short?, render?, onClick?, defaultVisible?, fixed?}], def?, settingsItem?, resetItem?, source?, sourceMode?})
152
+
153
+ <tb.Bar settings reset? /> // the live bar; settings adds a gear; reset is hidden by default; item moves animate
154
+ <tb.Settings /> // the PURE config editor - same element drops into a settings section
155
+ tb.api.useConfig() / getConfig() / setConfig(next) / setOrder(order) / show(key,on) / setDensity(key) / reset()
156
+ tb.api.showSettings(on) / showReset(on) // pseudo-controls visibility
157
+ tb.api.useItems() // headless bar: ordered visible [{item, density, content}] - custom markup
158
+ tb.api.onChange.on(cfg => ...) -> off // fires on every edit
159
+
160
+ registerToolbarDensity({key, name, renderItem?}) -> unregister // built-ins: 'icon', 'label'
161
+ toolbarItemIcon(item) // the item's icon, or its first letters as a text pseudo-icon
162
+ ```
163
+ Config `{order, visible, density}` is serializable and persisted like createUiSlot
164
+ (memoryGetOrCreate -> memoryCache, the app owns the write policy). Items added in an app update are merged
165
+ into a stale persisted config (appended, default-visible), removed ones are ignored. `icon` is
166
+ optional: in icon density an icon-less item renders the first letters of its short/title.
167
+ The gear and reset button are pseudo-items: `settingsItem: {title?, icon?}` and `resetItem` re-skin them,
168
+ and the editor has separated toggle rows for them (`visible['__settings']` / `visible['__reset']`, no order slots - they always sit at the bar edge). Settings is visible by default; reset is hidden by default unless `resetItem.defaultVisible: true` or `api.showReset(true)` enables it. `resetItem: false` removes the reset feature.
169
+
170
+ `source?` makes the toolbar a VIEW over an external `UiListSource`. Default `sourceMode:"orderVisible"`
171
+ means the source owns item order and item visibility, so one config can drive a grid, its icon menu
172
+ AND the toolbar together (`columnState.api.listSource`, QA card 31). `sourceMode:"order"` shares only
173
+ the source-key order; item membership, density, pseudo-controls, and extra non-source item positions
174
+ stay in the toolbar store (QA card 30). Without `source` the toolbar keeps its own store (standalone mode).
175
+
176
+ ## Callback Hub
177
+ ```
178
+ const hub = createCallbackHub<[Args]>(emit => api.onX(emit)) // one slot -> many subscribers
179
+ hub.on(cb) -> off
180
+ ```
181
+
182
+ ## Params
183
+ ```
184
+ <ParamsEditor
185
+ params={params}
186
+ onChange={next => setParams(next)}
187
+ onExpand={next => setParams(next)}
188
+ expandStatus?
189
+ expandStatusLvl?
190
+ />
191
+
192
+ const controller = useParamsEditorController({params, onChange, onExpand?})
193
+
194
+ <ParamsEdit params={params} onSave={next => save(next)} />
195
+ <ParamRow name="Name">...</ParamRow>
196
+ ```
197
+
198
+ ## Menu
199
+ ```
200
+ type MenuItem = { name, actionKey?, onClick?, next?, func?, status? } | false | null | undefined
201
+
202
+ <Menu data={items} coordinate={{x: 0, y: 0}} />
203
+
204
+ <contextMenu.Layer>{children}</contextMenu.Layer>
205
+ contextMenu.openAt(event, [{name: "Copy", actionKey: "grid.copy", onClick: copy}], {source: "grid"})
206
+ contextMenu.stats.getSnapshot() // local counters: opens, source/layer usage, actionTotals, keyed actions
207
+ contextMenu.close()
208
+ ```
209
+
210
+ Use `contextMenu.openAt(e, items, {source?})` for new right-click integrations. `contextMenu.map` still exists as a legacy Layer queue, but it is not the recommended path for new code. `contextMenu.stats` is an in-memory diagnostics API; it never persists data, never reports over the network, and records per-action counters only for explicit `actionKey` values. Unkeyed items are counted in aggregate totals without storing labels.
211
+
212
+ `Menu` keeps the hovered/open item in internal React state. `status` is only an initial open hint and a compatibility field passed to custom renderers; menu objects are not mutated.
213
+
214
+ Floating right menu:
215
+ ```
216
+ <DropdownMenu elements={items} trigger={iconOrRender} classNames={...} styles={...} />
217
+ const menu = useRightMenuController({elements, keyForSave?})
218
+ ```
219
+
220
+ `DropdownMenu` is a floating action menu with caller-owned trigger/content styling, not the main context-menu primitive. `useRightMenuController` exposes the same open/fixed/select/submenu/drag state for custom views; `DropdownMenu` remains the compatible visual wrapper.
221
+
222
+ ## agGrid4
223
+ Plain declarative rows:
224
+ ```
225
+ <AgGridTable<Row> rowData={rows} columnDefs={cols} />
226
+ ```
227
+
228
+ Buffered mirror table:
229
+ ```
230
+ const grid = useAgGrid<Row>({getId: row => row.id})
231
+ <AgGridTable<Row> controller={grid} columnDefs={cols} />
232
+
233
+ grid.update({newData: rows})
234
+ grid.remove([{id}])
235
+ grid.clean()
236
+ grid.fit()
237
+ grid.flush()
238
+ ```
239
+
240
+ Shared module store:
241
+ ```
242
+ export const mainTable = createGridBuffer<Row>({getId, externalBuffer})
243
+
244
+ const grid = useAgGrid({core: mainTable})
245
+ <AgGridTable<Row> controller={grid} columnDefs={cols} />
246
+ ```
247
+
248
+ Overlay patches over React-owned `rowData`:
249
+ ```
250
+ const core = createGridBuffer<Row>({getId, mode: "overlay", pushDefaults: {add: false}})
251
+ const grid = useAgGrid({core})
252
+
253
+ <AgGridTable<Row> controller={grid} rowData={rows} columnDefs={cols} getRowId={p => getId(p.data)} />
254
+ ```
255
+
256
+ Dynamic columns are pure names + lifecycle replay:
257
+ ```
258
+ const columns = createColumnBuffer<Row>()
259
+
260
+ columns.api.setNames(["a", "b"])
261
+ columns.control.attach(api, {
262
+ apply: ({api, names}) => api.setGridOption("columnDefs", buildColumnDefs(names)),
263
+ })
264
+ columns.api.apply()
265
+ columns.control.detach()
266
+ ```
267
+
268
+ `createColumnBuffer()` knows nothing about groups, `columnDefs`, business names, or where columns live.
269
+
270
+
271
+ ## columnState
272
+ A persisted column layer over a keyed column set - order / visibility / width / sort / filter in a
273
+ standalone config store, with an OPTIONAL two-way ag-grid adapter. Mobile card views consume the
274
+ SAME config with no ag-grid at all. agGrid4 wrappers are never modified: this is the app-level
275
+ column wrapper `WRAPPER.md` postulates, packaged as a primitive.
276
+ ```
277
+ const cs = createColumnState({key, columns: [{key, title, short?, icon?, group?, fixed?, defaultVisible?, cardRole?}], def?, saveMs?})
278
+
279
+ cs.columns // the descriptors (UI renders from these + config)
280
+ cs.api.useConfig() / getConfig() / setConfig(next) / reset()
281
+ cs.api.show(key, on) / move(order) / setSort({key, dir} | null) / toggleSort(key) // asc->desc->off
282
+ cs.api.visibleKeys() // keys to render, in order (group-gated)
283
+ cs.api.onChange.on(cfg => ...) -> off
284
+ cs.api.usePresent() / isPresent(key) / setPresent(keys | null) // live-grid presence
285
+ cs.api.getPresentGate() / setPresentGate(keys | null) // app runtime availability gate, not persisted
286
+ cs.api.listSource // {order, visible} slice as a Toolbar `source`
287
+ ```
288
+ Config `{v, order, visible, width, sort, filter, groups}` is serializable and persisted like
289
+ createToolbar (memoryGetOrCreate(key) -> memoryCache, the app owns the write policy); a stale config migrates
290
+ softly (unknown keys dropped, new columns appended default-visible, `fixed` pinned). `sort` is
291
+ STICKY: independent of visibility/selection, may point at a hidden or grid-absent column, changes
292
+ only by an explicit toggle/header click.
293
+
294
+ Two-way ag-grid adapter (opt-in, agGrid4 untouched):
295
+ ```
296
+ <AgGridTable columnDefs={cols} autoSizeColumns={false}
297
+ onGridReady={e => cs.grid.attach(e.api)} // restore saved layout, then watch the grid
298
+ onGridPreDestroyed={() => cs.grid.detach()} /> // config survives remounts
299
+ ```
300
+ Grid drag/resize/hide/sort/filter fold back into the config; UI edits apply to the grid; a column
301
+ removed from the live grid (dynamic defs) keeps its config entry and reads `isPresent==false`.
302
+
303
+ Icon menu - a 1:1 grid mirror (reorder in the grid <-> reorder the buttons):
304
+ ```
305
+ <ColumnsMenu state={cs} compact? marks? tail? onItem? onTail? reorder? /> // default click = toggle visibility
306
+ <MenuStrip items={[{key, title, state:'on'|'off'|'disabled', marks?, fixed?}]} onItem? onMove? tail? compact? />
307
+ ```
308
+ `ColumnsMenu` binds `MenuStrip` - a presentation-only layer that reports clicks/drags but never
309
+ interprets them - to the config. `compact` = icon-only buttons; an item with no icon shows its
310
+ first letters. `tail` = trailing non-column buttons (a table-standards cycler etc.). For new
311
+ settings-integrated toolbar/grid surfaces prefer `createToolbar({source: cs.api.listSource})`;
312
+ keep `ColumnsMenu` for compact button strips or custom presentation-only menus.
313
+
314
+ Mobile (no ag-grid): dots selector + card rows over the same config:
315
+ ```
316
+ <ColumnDots state={cs} max?=8 /> // track of marks; dots = shown columns; tap empty=add,
317
+ // drag=replace, swipe up=remove, tap dot=select, sort button cycles
318
+ <CardList<Row> state={cs} data={rows} getId? renderValue? /> // visible cols -> card fields; cardRole:'title'/'accent'
319
+ ```
320
+ QA cards 28 (grid layer + F5 restore), 29 (mobile dots + cards), 30 (toolbar icon menu + grouped sub-column mode button), 31 (Toolbar over the same config).
321
+
322
+
323
+ ## Observe React Adapter
324
+ `wenay-common2` owns the store/listen/RPC primitives. `wenay-react2` owns React lifecycle hooks around them.
325
+
326
+ ```ts
327
+ import { Observe } from "wenay-common2"
328
+ import { useStoreMirror, useStoreNode, useStoreKeys, useStoreSelect, useStoreChangedPaths, useListenEffect, useListenValue } from "wenay-react2"
329
+ ```
330
+
331
+ Node subscription:
332
+ ```
333
+ const price = useStoreNode(store.node.data.BTC)
334
+ price.value
335
+ price.exists
336
+ price.replace(123)
337
+ price.refresh()
338
+ ```
339
+
340
+ Selection subscription:
341
+ ```
342
+ const sel = useStoreSelect(store.update({data: {BTC: true}, meta: {status: true}}))
343
+ sel.value
344
+ sel.get()
345
+ ```
346
+
347
+ Network mirror is explicit:
348
+ ```
349
+ const mirror = useStoreMirror(remoteStore, {data: {}, meta: {}}, {
350
+ mask: {data: {BTC: true}, meta: {status: true}},
351
+ current: true,
352
+ drain: 250,
353
+ })
354
+
355
+ mirror.value
356
+ mirror.ready
357
+ mirror.sync()
358
+ mirror.stop()
359
+ ```
360
+
361
+ Per-key feed (`store.each()` of common2 — one cb per CHANGED top-level key):
362
+ ```
363
+ useStoreEach(store, (key, value, ctx) => {
364
+ value === undefined ? removeRow(key) : upsertRow(key, value)
365
+ }, {enabled?})
366
+ ```
367
+ `undefined` = key deleted; a root replace (store.replace / mirror keyframe) expands into one call per key. The fold target should live outside React state (ref/Map/grid api) — the hook renders nothing itself. The wire counterpart is `useStoreReplayEach` below.
368
+
369
+ Listen helpers:
370
+ ```
371
+ useListenEffect(listen, (...args) => {})
372
+ const value = useListenValue(listen, {initial})
373
+ const args = useListenArgs(listen)
374
+ ```
375
+
376
+ The hook does not choose transport. `remoteStore` needs `{ get(mask?), changed }` and may also provide `changedPaths`; apps may implement it with RPC, WebSocket, SSE, or test HTTP. `changedPaths` is used as a transport optimization: mirror pulls `mask ∩ paths` instead of the whole mask. `initial` is only the local mirror seed; changing it does not reset an existing mirror. `mask` is treated as a small declarative StoreMask, so structurally equal inline masks do not resubscribe. For add/delete keys, subscribe to the parent object node with `useStoreKeys(node)`; this also covers deep objects. If a state key conflicts with StoreNode methods such as `count`, use `store.node.at("count")`.
377
+
378
+ ## Replay React Adapter
379
+ Client-side hooks over the wenay-common2 Replay stack (snapshot + sequenced delta line). Server parts (`conflateReplay`, `archiveReplay`, `createRpcServerAuto` replayOpts) are per-connection and stay hook-free by design.
380
+
381
+ ```ts
382
+ import { useReplaySubscribe, useReplayRouteSubscribe, useStoreReplaySync, useStoreReplayMirror, useStoreReplayRouteSync, useStoreReplayRouteMirror, useStoreReplayEach, useReplayFrame, useReplayHistory } from "wenay-react2"
383
+
384
+ // any replay line ({line, since, keyframe, frame?, frameLine?} remote)
385
+ const sub = useReplaySubscribe(remote, (frame) => draw(frame), {onSeq?, onError?, since?, enabled?, staleMs?, onStale?, policy?, hint?})
386
+ sub.ready; sub.error; sub.seq(); sub.restart()
387
+ sub.stale; sub.lastTs() // freshness (needs staleMs): stale re-renders on fresh<->stale transitions ONLY; lastTs() is a getter like seq()
388
+ // policy: 'queue' (default, nothing skipped) | 'frame' (rides the server frameLine when present: on lag the
389
+ // server drops and recovers with a mini-frame; old servers/in-proc degrade to 'queue'). hint -> the line's frame condenser.
390
+
391
+ // explicit route hand-off over the same logical replay line (relay <-> direct, etc.)
392
+ const routed = useReplayRouteSubscribe(relayRemote, (frame) => draw(frame), {label: "relay", onRoute?})
393
+ await routed.switchRoute(directRemote, {label: "direct"})
394
+ routed.ready; routed.switching; routed.route; routed.seq(); routed.label(); routed.active()
395
+ // route hooks do not expose stale/lastTs yet; use non-route hooks when freshness is required.
396
+ // store patch line (Observe.exposeStoreReplay(...).api.replay)
397
+ const mirror = useStoreReplayMirror(remote, initial, {enabled?}) // creates the mirror store; same staleMs/stale/policy surface
398
+ mirror.store; mirror.ready; mirror.seq(); mirror.restart()
399
+ const sync = useStoreReplaySync(existingStore, remote) // same, store supplied by the app
400
+ const routedSync = useStoreReplayRouteSync(existingStore, relayRemote, {label: "relay"}) // same store, switchRoute() replaces the route after catch-up
401
+ const routedMirror = useStoreReplayRouteMirror(relayRemote, initial, {label: "relay"}) // mirror store + route hand-off controller
402
+
403
+ // per-key fold over the same line (Observe.syncStoreReplayEach counterpart): cb per CHANGED top-level key,
404
+ // first delivery = keyframe expanded per key, (key, undefined) = deleted — grid rows without special cases
405
+ const feed = useStoreReplayEach<Rows>(remote, (key, row) => row === undefined ? removeRow(key) : upsertRow(key, row), {drain?, initial?})
406
+ feed.store; feed.ready; feed.stale; feed.seq(); feed.restart() // same controller surface as the mirror
407
+ // the mirror store lives in a ref: in-mount resubscribes (StrictMode/restart/enabled) reconnect by tail ON TOP of
408
+ // kept state — the consumer sees only the diff. After a full unmount: {since: prev.seq(), initial: prev.store.snapshot()}
409
+
410
+ // pull at YOUR pace (frame model): timer around remote.frame(seq, hint) — server condenses, no backlog, no live socket sub
411
+ const pf = useReplayFrame(remote, (quote) => apply(quote), {intervalMs: 500, hint?})
412
+ pf.ready; pf.error; pf.seq(); pf.pull(); pf.restart() // error (e.g. sacred line evicted) STOPS pulling until restart()
413
+
414
+ // time machine over Replay.openHistory(storage, live?)
415
+ const tt = useReplayHistory(history, (frame) => draw(frame), {head: () => replay.head()})
416
+ tt.live; tt.seq; tt.head; tt.pause(); tt.play(); tt.seek({seq})
417
+ ```
418
+
419
+ Contract: `off()` on unmount, StrictMode-safe; seq survives resubscribes inside one mount (keepSeq, default on) — a resubscribe reconnects with `{since}` and gets the journal tail, not a keyframe. Across a FULL unmount/remount keep the position outside via `onSeq` and pass it back as `since`. `seq()` is a getter — high-frequency lines (video frames, ticks) do not re-render per event; draw to canvas via ref, bypassing VDOM. Freshness: detection lives in wenay-common2 (`staleMs` watchdog); the non-route hooks mirror its edge-triggered `onStale` into `stale`, so a fresh 100 ev/s line causes zero extra renders. Route hand-off is explicit through `switchRoute(nextRemote, {label?, since?, reset?, policy?, hint?})`: old route stays live while the replacement catches up by `seq`, then closes. `useReplayHistory` is archive playback — staleness does not apply. QA cards 23 (video line + conflation + time travel + freshness), 24 (store sync), 25 (per-key feed), and 26 (route hand-off) are the live examples.
420
+
421
+ ## Logs
422
+ ```
423
+ logsApi.addLogs({id: "api", time: new Date(), txt: "done", var: 1})
424
+ <logsApi.React.Message />
425
+ <logsApi.React.PageLogs />
426
+ <logsApi.React.Setting />
427
+
428
+ const customLogs = getLogsApi<MyFields>({limitPer: 500, limit?: 50, varMin?: 0})
429
+ const headless = createLogsController<MyFields>({options: {limitPer: 500, limit: 50}})
430
+
431
+ const messageNotifications = useMessageEventLogsController({maxVisible: 4})
432
+ <MessageEventLogsView controller={messageNotifications} zIndex={80} />
433
+
434
+ const contextTable = useLogsTableController()
435
+ const contextNotifications = useLogsNotificationsController()
436
+
437
+ const mini = useMiniLogsTable({data: rows, onClick: e => console.log(e.data)})
438
+ <AgGridTable {...mini.props} />
439
+ <MiniLogsTable data={rows} />
440
+ ```
441
+
442
+ Logger notification/tabs chrome uses `--logs-*` CSS variables; apps can theme it without forking logger components. `createLogsController` owns the headless append/limit/settings state for custom integrations. `useMessageEventLogsController` owns the global notification queue/timers/settings, `MessageEventLogsView` draws it, and `MessageEventLogs` / `logsApi.React.Message` remain compatibility wrappers. `MiniLogs` remains the compatibility wrapper; new compact-table code can use `useMiniLogsTable` or `MiniLogsTable`.
443
+
444
+ ## Styles / Theme
445
+ ```
446
+ tokens.color.bgDark
447
+ tokens.menu.outlineColor
448
+ tokens.logs.notificationAccent
449
+ tokens.zIndex.modal
450
+
451
+ GridStyleDefault() // inject legacy grid CSS vars/classes
452
+ StyleGridDefault // common ag-grid style object
453
+ buildAgTheme("dark" | "light")
454
+ useAgGridTheme("dark" | "light")
455
+ colDefCentered / colDefWrap / numericComparator
456
+ ```
457
+
458
+ Shared CSS variables include `--menu-outline-color`, `--logs-*`, `--dlg-*`, `--wnd-*`, `--tb-*`, `--cols-menu-*`, `--cols-dots-*`, and `--cols-card-*`.
459
+
460
+ ## Charts
461
+ ```
462
+ <MyChartEngine style={{height: 400}} />
463
+ createChartCanvas(config) -> canvas controller
464
+ ```
465
+
466
+ The chart engine surface is still mostly low-level. Keep product-specific chart wrappers in the app.
467
+
468
+ ## QA Stand
469
+ ```
470
+ npm run testReact // http://localhost:3010/
471
+ ```
472
+
473
+ The stand lives in `src/common/testUseReact/qa.tsx`. Use it for visual checks; agGrid4 overlay/dynamic-column demos are dedicated QA cards.
474
+ Two tabs: Active checks (current work) and Verified archive (`#archive`) - verified/fixed cards move
475
+ there and stay runnable for regression re-checks.
@@ -1,3 +1,4 @@
1
+ import React from "react";
1
2
  import { getSettingLogs, type LogEntry, type LogsApiOptions } from "./logsController";
2
3
  export { createLogsController, createLogsControllerState, getSettingLogs, } from "./logsController";
3
4
  export type { CreateLogsControllerOptions, LogsApiOptions, LogsController, LogsControllerState, LogsFullState, LogsMiniState, } from "./logsController";
@@ -38,6 +39,32 @@ declare function InputSettingLogs({}: {
38
39
  export declare function PageLogs({ update }: {
39
40
  update?: number;
40
41
  }): import("react/jsx-runtime").JSX.Element;
42
+ export type MessageEventLogsItem = {
43
+ key: string;
44
+ logs: LogEntry;
45
+ };
46
+ export type UseMessageEventLogsControllerOptions = {
47
+ maxVisible?: number;
48
+ };
49
+ export type MessageEventLogsController = {
50
+ show: boolean;
51
+ setShow(value: boolean): void;
52
+ toggleShow(): void;
53
+ notifications: MessageEventLogsItem[];
54
+ visibleNotifications: MessageEventLogsItem[];
55
+ maxVisible: number;
56
+ };
57
+ export type MessageEventLogsViewProps = {
58
+ controller: MessageEventLogsController;
59
+ zIndex?: number;
60
+ className?: string;
61
+ style?: React.CSSProperties;
62
+ };
63
+ export declare function MessageEventLogCard({ logs }: {
64
+ logs: LogEntry;
65
+ }): import("react/jsx-runtime").JSX.Element;
66
+ export declare function useMessageEventLogsController(options?: UseMessageEventLogsControllerOptions): MessageEventLogsController;
67
+ export declare function MessageEventLogsView({ controller, zIndex, className, style }: MessageEventLogsViewProps): import("react/jsx-runtime").JSX.Element;
41
68
  export declare function MessageEventLogs({ zIndex }: {
42
69
  zIndex?: number;
43
70
  }): import("react/jsx-runtime").JSX.Element;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef } from "react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { copyToClipboard, Params, timeLocalToStr_hhmmss } from "wenay-common2";
4
4
  import { renderBy, updateBy } from "../../updateBy";
5
5
  import { contextMenu } from "../menu/menuMouse";
@@ -153,7 +153,7 @@ export function PageLogs({ update }) {
153
153
  }, [true]);
154
154
  return _jsx(Main, {});
155
155
  }
156
- function Message({ logs }) {
156
+ export function MessageEventLogCard({ logs }) {
157
157
  return _jsxs("div", { className: "testAnime", style: { width: "200px", color: logStyleTokens.text, height: "auto", marginTop: "10px", borderRight: `5px solid ${logStyleTokens.accent}`, background: logSeverityBackground(logs.var) }, children: [_jsx("p", { style: { textAlign: "center", fontSize: "10px", marginBottom: "1px" }, children: "notification" }), _jsx("hr", { style: {
158
158
  backgroundImage: logDividerGradient(),
159
159
  border: 0,
@@ -161,35 +161,84 @@ function Message({ logs }) {
161
161
  margin: "0 0 0 0",
162
162
  boxSizing: "content-box",
163
163
  display: "block"
164
- } }), _jsx("div", { style: { textAlign: "right", marginRight: "10px", height: "auto", overflowWrap: "break-word", textOverflow: "ellipsis" }, children: typeof logs.txt == "object" ? JSON.stringify(logs.txt) : logs.txt }), _jsx("p", { style: { float: "inline-end", textAlign: "right", marginRight: "10px" }, children: (new Date()).toLocaleDateString() })] });
164
+ } }), _jsx("div", { style: { textAlign: "right", marginRight: "10px", height: "auto", overflowWrap: "break-word", textOverflow: "ellipsis" }, children: typeof logs.txt == "object" ? JSON.stringify(logs.txt) : logs.txt }), _jsx("p", { style: { float: "inline-end", textAlign: "right", marginRight: "10px" }, children: (new Date(logs.time)).toLocaleDateString() })] });
165
165
  }
166
- const tt = {};
167
- let r = 0;
168
- export function MessageEventLogs({ zIndex }) {
166
+ export function useMessageEventLogsController(options = {}) {
169
167
  const setting = memoryGetOrCreate("settingLogs", settingLogs);
170
- updateBy(tt);
171
- updateBy(datumMiniConst, () => {
172
- const last = datumMiniConst.last[0];
173
- if (!last)
168
+ const maxVisible = Math.max(1, options.maxVisible ?? 10);
169
+ const [notifications, setNotifications] = useState([]);
170
+ const counterRef = useRef(0);
171
+ const lastLogRef = useRef(null);
172
+ const timersRef = useRef(new Map());
173
+ updateBy(setting);
174
+ const addNotification = useCallback((last) => {
175
+ if ((last.var ?? 0) < (setting.params.minVarMessage ?? 0))
174
176
  return;
175
- if (setting.params.minVarMessage && (!last.var || last.var < setting.params.minVarMessage))
177
+ const key = String(counterRef.current++);
178
+ const item = { key, logs: last };
179
+ const displayMs = (setting.params.timeShow ? setting.params.timeShow : 2) * 1000;
180
+ setNotifications(prev => [item, ...prev]);
181
+ const timer = setTimeout(() => {
182
+ timersRef.current.delete(key);
183
+ setNotifications(prev => prev.filter(e => e.key !== key));
184
+ }, displayMs);
185
+ timersRef.current.set(key, timer);
186
+ }, [setting]);
187
+ const onMiniChange = useCallback(() => {
188
+ const last = datumMiniConst.last[0];
189
+ if (!last || last === lastLogRef.current)
176
190
  return;
177
- let key = String(r++);
178
- tt[key] = _jsx("div", { className: "example-exit", children: _jsx(Message, { logs: last }) }, key);
179
- setTimeout(() => {
180
- if (tt[key]) {
181
- delete tt[key];
182
- renderBy(tt, 100);
183
- }
184
- }, setting.params.timeShow ? setting.params.timeShow * 1000 : 2000);
185
- renderBy(tt);
186
- });
187
- const tr = [...Object.values(tt)].reverse().slice(0, 10);
188
- return _jsxs("div", { style: { maxHeight: "50vh", position: "absolute", right: "1px", zIndex }, children: [_jsx("div", { onClick: () => { setting.params.show = !setting.params.show; renderBy(tt); }, style: { margin: 3, padding: 3, right: 0, position: "absolute", zIndex: 120,
189
- ...setting.params.show ?
190
- { background: logStyleTokens.toggleBg, fontSize: "25px" } :
191
- { background: logStyleTokens.toggleOffBg }
192
- }, children: setting.params.show ? "X" : "log" }), _jsx("div", { children: setting.params.show ? tr : null })] });
191
+ lastLogRef.current = last;
192
+ addNotification(last);
193
+ }, [addNotification]);
194
+ updateBy(datumMiniConst, onMiniChange);
195
+ useEffect(() => () => {
196
+ timersRef.current.forEach(clearTimeout);
197
+ timersRef.current.clear();
198
+ }, []);
199
+ const setShow = useCallback((value) => {
200
+ setting.params.show = value;
201
+ renderBy(setting);
202
+ }, [setting]);
203
+ const toggleShow = useCallback(() => {
204
+ setShow(!setting.params.show);
205
+ }, [setShow, setting]);
206
+ return useMemo(() => ({
207
+ show: Boolean(setting.params.show),
208
+ setShow,
209
+ toggleShow,
210
+ notifications,
211
+ visibleNotifications: notifications.slice(0, maxVisible),
212
+ maxVisible,
213
+ }), [maxVisible, notifications, setShow, setting.params.show, toggleShow]);
214
+ }
215
+ export function MessageEventLogsView({ controller, zIndex, className, style }) {
216
+ const toggleTitle = controller.show ? "Hide log notifications" : "Show log notifications";
217
+ const toggleStyle = {
218
+ position: "absolute",
219
+ right: 0,
220
+ top: 0,
221
+ zIndex: 120,
222
+ minWidth: controller.show ? 24 : 42,
223
+ height: 24,
224
+ padding: controller.show ? 0 : "0 8px",
225
+ border: `1px solid ${logStyleTokens.accent}`,
226
+ borderRadius: 999,
227
+ background: controller.show ? logStyleTokens.toggleBg : logStyleTokens.toggleOffBg,
228
+ color: logStyleTokens.text,
229
+ cursor: "pointer",
230
+ display: "inline-flex",
231
+ alignItems: "center",
232
+ justifyContent: "center",
233
+ fontSize: controller.show ? 18 : 12,
234
+ lineHeight: 1,
235
+ boxShadow: "0 2px 8px rgba(0, 0, 0, 0.35)",
236
+ };
237
+ return _jsxs("div", { className: className, style: { maxHeight: "50vh", position: "absolute", right: "1px", zIndex, ...style }, children: [_jsx("button", { type: "button", "aria-label": toggleTitle, title: toggleTitle, onClick: controller.toggleShow, style: toggleStyle, children: controller.show ? "\u00d7" : "log" }), _jsx("div", { children: controller.show ? controller.visibleNotifications.map(e => (_jsx("div", { className: "example-exit", children: _jsx(MessageEventLogCard, { logs: e.logs }) }, e.key))) : null })] });
238
+ }
239
+ export function MessageEventLogs({ zIndex }) {
240
+ const controller = useMessageEventLogsController();
241
+ return _jsx(MessageEventLogsView, { controller: controller, zIndex: zIndex });
193
242
  }
194
243
  const defPageBase = {
195
244
  keyPage: "PageLogs"