wenay-react2 1.0.47 → 1.0.48

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 (44) hide show
  1. package/README.md +14 -14
  2. package/doc/EXAMPLE_USAGE.md +969 -969
  3. package/doc/PROJECT_FUNCTIONALITY.md +403 -403
  4. package/doc/PROJECT_RULES.md +27 -27
  5. package/doc/WENAY_REACT2_RENAMES.md +170 -170
  6. package/doc/changes/v1.0.35.md +18 -18
  7. package/doc/changes/v1.0.38.md +37 -37
  8. package/doc/changes/v1.0.39.md +23 -23
  9. package/doc/changes/v1.0.40.md +14 -14
  10. package/doc/changes/v1.0.41.md +35 -35
  11. package/doc/changes/v1.0.42.md +26 -26
  12. package/doc/changes/v1.0.43.md +10 -10
  13. package/doc/changes/v1.0.44.md +14 -14
  14. package/doc/changes/v1.0.45.md +8 -8
  15. package/doc/changes/v1.0.46.md +11 -11
  16. package/doc/changes/v1.0.47.md +9 -9
  17. package/doc/changes/v1.0.48.md +8 -0
  18. package/doc/examples/README.md +4 -4
  19. package/doc/examples/peer-call-media.tsx +7 -7
  20. package/doc/examples/stand.tsx +5 -5
  21. package/doc/native.md +37 -37
  22. package/doc/progress/README.md +13 -13
  23. package/doc/progress/architecture-fix-queue.md +74 -74
  24. package/doc/progress/column-state-present-gate.md +28 -28
  25. package/doc/progress/common2-adoption-1.0.73.md +28 -28
  26. package/doc/progress/common2-adoption-1.0.74.md +24 -24
  27. package/doc/progress/hook-controller-opportunities.md +363 -363
  28. package/doc/progress/hook-extraction-audit.md +195 -195
  29. package/doc/progress/public-surface-normalization.md +351 -351
  30. package/doc/progress/qa-stand-walkthrough-2026-07-12.md +62 -0
  31. package/doc/progress/stand-as-examples-audit.md +20 -20
  32. package/doc/progress/style-system-normalization.md +121 -121
  33. package/doc/target/README.md +32 -32
  34. package/doc/target/my.md +122 -122
  35. package/doc/wenay-react2-rare.md +807 -807
  36. package/doc/wenay-react2.md +541 -541
  37. package/lib/common/demo/peerMedia.js +6 -1
  38. package/lib/common/src/logs/logsController.js +2 -2
  39. package/lib/common/testUseReact/qa.js +3 -3
  40. package/lib/style/menuRight.css +19 -19
  41. package/lib/style/style.css +23 -23
  42. package/lib/style/tokens.css +184 -184
  43. package/package.json +1 -1
  44. package/doc/wenay-react2-1.0.8.tgz +0 -0
@@ -1,807 +1,807 @@
1
- # wenay-react2 - EXTENDED / rare surface
2
-
3
- > Everyday API lives in **`wenay-react2.md`**.
4
- > This file lists low-level primitives and migration notes.
5
- > Root import: `import { ... } from "wenay-react2"`.
6
-
7
- ## Migration Rule
8
- ```
9
- New code teaches and imports the short canonical surface:
10
- useModal(), useOutside(), useDraggableApi(), useReorder(), useReorderBoard(),
11
- useAgGrid(), createGridBuffer(), createColumnBuffer(), createUiSlot(), createToolbar()
12
-
13
- Old names are recorded only in `WENAY_REACT2_RENAMES.md`; do not export new compatibility aliases casually. Existing public APIs should usually remain supported during internal refactors; removals require an explicit migration cut with a separate task, migration notes, changelog, and usage signal. Default style contracts are stricter: do not remove old default classes/styles until the replacement class/token path is shipped and documented.
14
- Do not add new examples with Get*, FuncJSX, *2/*3, or business-specific helper names.
15
- If a primitive needs app policy, build a small app wrapper above it.
16
- If behavior is reusable, expose it first as a headless use*/create* API. The hook/controller may return methods, getters, props/bind helpers, or a small API object; visual components and QA cards should consume that surface instead of becoming the only implementation.
17
- ```
18
-
19
- Canonical method vocabulary:
20
- ```
21
- open / close / set / replace // modal-like state
22
- update / remove / clean / sync // data-buffer state
23
- fit / flush // ag-grid visual/transaction lifecycle
24
- props / bind // spreadable element props
25
- get / set / reset / cancel // local hook/controller state
26
- on -> off // subscriptions
27
- ```
28
-
29
- ## Root Namespaces
30
- ```
31
- import { kit } from "wenay-react2"
32
-
33
- kit.hooks
34
- kit.dnd
35
- kit.utils
36
- kit.grid
37
- kit.modal
38
- kit.menu
39
- kit.logs
40
- kit.updateBy
41
- ```
42
-
43
- The root export is still flat. `kit` is useful when a large file needs grouped names.
44
-
45
- ## Modal Low Level
46
- ```
47
- useModal() // callable controller with show/open/close/set
48
- createModalElementStore() // imperative JSX store; prefer ModalProvider/useModal
49
- createModalRenderStore() // function JSX store; prefer ModalProvider/useModal
50
- inputModal({modal, func, name?, txt?})
51
- confirmModal({modal, func, password?})
52
- ```
53
-
54
- `inputModal` and `confirmModal` accept either a setter function or the `useModal()` controller.
55
-
56
- Internal Overlay (A9, 2026-07-10): `components/Overlay.tsx` is the ONE portal+scrim+outside-click+
57
- Escape composition; `ModalProvider` and `SettingsDialog` are adapters over it (behavior 1:1,
58
- SettingsDialog keeps its own two-stage Escape by NOT passing `onEscape`). It is deliberately not
59
- exported: apps use ModalProvider/useModal/SettingsDialog. The render-slot stores above, the
60
- LeftModal drawer (gesture-driven, scrim-less), and `ModalWrapper`/Input helpers (backdrop-less,
61
- hosted inside another modal slot) are NOT overlays and stay separate. This leaf is also where all
62
- scrim/portal DOM lives - the seam for a future react-native view layer.
63
-
64
- Left-side modal/menu helpers:
65
- ```
66
- LeftModal({arr, zIndex})
67
- ApiLeftMenu
68
- getApiLeftMenu()
69
- TestLeft333()
70
- ```
71
-
72
- These are app-shell style utilities. Prefer local app wrappers for new layouts.
73
-
74
- ## Settings Dialog / UI Slot / Callback Hub Details
75
- Settings dialog (centered panel ~720x480, max 92vw/82vh; searchable settings tree left, content right):
76
- ```
77
- type SettingsSection = {key: string, name: string, render: () => ReactNode, children?, parentKey?, searchText?, keywords?}
78
-
79
- <SettingsDialog
80
- trigger={...} // wrapper span is clickable
81
- sections? // static sections, listed first; children form tree nodes
82
- defaultSection? // falls back to the first section when missing/unmounted
83
- sectionClassName? // apps pass their own .chip; default .wenayDlgSection
84
- sectionActiveClassName? // apps pass their own .chipActive; default .wenayDlgSectionActive
85
- />
86
-
87
- useSettingsDialogController({sections?, defaultSection?, sectionClassName?, sectionActiveClassName?})
88
- registerSettingsSection(s) -> unregister
89
- getSettingsSections() -> readonly SettingsSection[]
90
- ```
91
- The registry is a module singleton on updateBy/renderBy (no React context). Registering the same
92
- `key` replaces the previous section; unregister removes by identity, so a stale unregister after a
93
- replacement is a no-op. Tree shape comes from nested `children` or flat sections with `parentKey`.
94
- Search matches section labels, `keywords`, `searchText`, and best-effort text extracted from rendered
95
- React children; matching descendants stay visible and their ancestors auto-expand. The search input uses
96
- `createSearchHistory({key:"SettingsDialog.searchHistory"})`: Enter commits the current query, the history
97
- button recalls stored queries, leaving the search box closes the list, and changes are published through memoryProps -> memoryCache. `useSettingsDialogController` exposes the same open/search/tree/history/nav-resize actions for custom settings chrome. Tree controls
98
- are one compact dotted cycle button in the search row (expanded -> current branch -> collapsed), hidden when
99
- the tree has too little hierarchy to need it. Closes on the x, a scrim click, and Escape.
100
- Styling: `--dlg-scrim / bg / border / radius / shadow / nav-bg / nav-width` tokens (tokens.css,
101
- mirror `tokens.dlg`), classes `.wenayDlgScrim / .wenayDlg / .wenayDlgNav / .wenayDlgSearch /
102
- .wenayDlgTree / .wenayDlgContent / .wenayDlgSearchHistory` in style.css; dark defaults, apps re-skin
103
- via `:root[data-theme]` like `--wnd-*`.
104
- UI slot:
105
- ```
106
- createUiSlot({key, places, def}) -> {Slot, PlacementSetting, getPlace, setPlace}
107
- <slot.PlacementSetting className? activeClassName? /> // defaults .wenaySegBtn / .wenaySegBtnActive
108
- ```
109
- State lives in `memoryGetOrCreate(key)` -> persisted with the rest of memoryProps; `setPlace`
110
- announces the in-place mutation via `memoryMarkDirty(key)` and the APP decides when to save
111
- via `memoryCache.onDirty` (the library never writes storage itself, same as window state).
112
- A stored place that no longer exists in `places` falls back to `def`. `getPlace()` is not reactive;
113
- Slot/PlacementSetting subscribe internally via updateBy.
114
-
115
- Toolbar (createToolbar, `components/Toolbar/Toolbar.tsx`):
116
- ```
117
- createToolbar({key, items, def?, settingsItem?, resetItem?, source?, sourceMode?}) -> {Bar, Settings, api: {useConfig, useItems, getConfig, setConfig, setOrder, show, setDensity, showSettings, showReset, reset, onChange}}
118
- <tb.Bar className? settings? reset? popAlign? /> // default classes .wenayTb / .wenayTbItem; popAlign
119
- // 'right' (default, top-right bars) | 'left'
120
- <tb.Settings className? activeClassName? /> // density segments default .wenaySegBtn(Active)
121
- registerToolbarDensity({key, name, renderItem?}) / getToolbarDensities()
122
- toolbarItemIcon(item) -> ReactNode // item.icon, or first letters of short/title
123
- ```
124
- Three decoupled layers: serializable config `{order, visible, density}` (single source of truth,
125
- persisted exactly like createUiSlot: memoryGetOrCreate(key), edits mutate in place + renderBy +
126
- memoryMarkDirty, the APP saves via memoryCache.onDirty), Bar (visible!==false, in order, at density) and
127
- a PURE Settings editor over config - the same element works in the Bar's gear popover and in a
128
- registered settings section, no prop changes. Semantics that are easy to get wrong:
129
- - Merge of stale persist: unknown keys in stored order/visible are ignored (view-level; raw
130
- storage is untouched until the next setConfig), items missing from the config are APPENDED
131
- default-visible - an app update never wipes the user's order/visibility.
132
- - `fixed` items: always visible, pinned at normalize() to their index in opts.items (checkbox
133
- disabled, not draggable) - and the drag PREVIEW already respects the pinning, so they never
134
- move visually and a drop never lands elsewhere than shown.
135
- - Density is an extensible module registry (like registerSettingsSection): entries may carry
136
- `renderItem(item)`; item.render(density) always wins; entries without renderItem fall back to
137
- icon + (short ?? title). A persisted density that is no longer registered falls back to
138
- def/first. Bar/Settings subscribe to the registry (updateBy), so registering a level updates
139
- live.
140
- - Reorder rides the library's own `useReorder` (see Drag / Resize Low Level; this editor is its
141
- first consumer): the WHOLE row drags, mouse+touch, checkbox excluded, and `move` is the SAME
142
- simulated commit as normalize() (splice + fixed pinning), so preview and drop agree by
143
- construction. Neighbours glide via transitioned transforms (`.wenayTbRowShift`), ONE setConfig
144
- on drop. Plus arrow keys on the focused handle (the drag hook preventDefaults mousedown, so the
145
- row handler focuses the handle itself). No dnd dependency; FloatingWindow/react-rnd deliberately not
146
- used (free-floating windows, wrong tool). `touch-action: none` + `user-select: none` on
147
- draggable rows (`.wenayTbRowGrab`).
148
- - `api.onChange` is a wenay-common2 `listen` stream; emits the NORMALIZED config after every
149
- config edit. useConfig subscribes via updateBy; getConfig is a non-reactive snapshot.
150
- `setOrder(order)` is the focused external-control path for grid/menu reorder sync: it preserves
151
- current membership, density, and pseudo-control visibility while changing only item order. `show(key,on)`,
152
- `setDensity(key)`, `showSettings(on)`, and `showReset(on)` are the matching narrow writes; `setConfig` remains the low-level full write.
153
- - The gear and reset buttons are pseudo-items: reserved visible keys `__settings` and `__reset`,
154
- NO order slots (they always sit at the bar edge), toggled from separated rows at the bottom of
155
- the editor (`.wenayTbRowMeta`, outside the drag-slot container on purpose). Hiding the gear in
156
- its own popover closes the popover - the global settings section is the way back. The reset
157
- button calls `api.reset()` and restores defaults for order, membership, density and pseudo-control
158
- visibility. Settings is default-visible; reset is default-hidden unless `resetItem.defaultVisible=true` or `api.showReset(true)` enables it. `resetItem: false` removes that feature. Faces via `settingsItem` / `resetItem`.
159
- - **The Settings CONTAINER is the client's choice (not the core's).** `tb.Settings` is
160
- presentation-agnostic - render it anywhere. `<Bar settings>`'s gear opens it in a small inline
161
- popover anchored to the gear; since the bar reflows when you toggle membership, that popover
162
- shifts a little (known minor twitch). The core deliberately ships ONLY that inline popover -
163
- anything richer is a client layer. For a stable, movable editor render `tb.Settings` in your own
164
- container: a registered settings section (`registerSettingsSection`), or a draggable window -
165
- `FloatingWindow` (drag by header, close X, viewport-clamped, position persisted via `keyForSave`)
166
- wrapped in `OutsideClickArea` for close-on-outside-click. Recommended pattern for clients who
167
- want the modal-with-title-bar-and-close look; QA card 30 demonstrates it (gear -> Settings in a
168
- FloatingWindow, in the card's own positioned layer so it scrolls with the card). This is separate
169
- from the row REORDER above, which stays on `useReorder` (a floating window would be the wrong tool
170
- there).
171
- - `api.useItems()` is the headless bar: ordered, visibility-filtered `[{item, density, content}]`
172
- for fully custom markup. Refs-out was rejected deliberately: order lives in the config, so the
173
- consumer re-renders from this list - the library never re-parents foreign DOM nodes.
174
- - `icon` is OPTIONAL: `toolbarItemIcon(item)` returns the icon, else the first 3 letters of
175
- short/title as a text pseudo-icon (icon density only - the label density shows the caption, not
176
- the letters). Exported so menus (ColumnsMenu compact) share the exact rule.
177
- - `source?: UiListSource` inverts ownership. Default `sourceMode:"orderVisible"` makes the toolbar a
178
- VIEW over source order+visibility: setConfig writes item order/visibility THERE (its own change
179
- flow re-emits `api.onChange`; the toolbar emits itself only if the source has no `onChange`), while
180
- density and the gear/reset flags stay in the toolbar's own `st` under `key`. `sourceMode:"order"`
181
- shares only the relative order of keys present in the source; item membership, density,
182
- pseudo-controls, and extra non-source item positions stay local. On source reorders, source keys
183
- refill their slots in the local toolbar order, so an extra item like QA card 30's `blockMode` is
184
- not pushed into `columnState` and does not need a bridge hook. `ext` is per-instance constant, so
185
- hook-call order in useConfig/Bar/Settings never changes. `columnState.api.listSource` is the
186
- reference implementer (QA card 31 for order+visibility, QA card 30 for order-only). Without a source
187
- - v1 non-goals: no overflow/"more" menu (hook point: Bar, after the visible-items map), no
188
- grouping, no cross-bar drag.
189
- Styling: `--tb-*` tokens (tokens.css, mirror `tokens.tb`), classes `.wenayTb*` in style.css;
190
- dark defaults, apps re-skin via `:root[data-theme]` like `--wnd-*` / `--dlg-*`.
191
-
192
- Callback hub (for single-slot callback APIs `onX(cb | null)` whose subscribers silently
193
- overwrite each other):
194
- ```
195
- createCallbackHub<Args>(bind) -> {on, count}
196
- ```
197
- `bind(emit)` runs lazily, ONCE, on the first `on()` - not at creation time, because the slot may
198
- still be taken by app initialization. The first callback is registered before bind runs, so it
199
- also catches synchronous emits. `on(cb) -> off`; off removes only that subscriber.
200
-
201
- ## columnState (createColumnState, `grid/columnState/`)
202
- A persisted column layer over a keyed column set - order/visibility/width/sort/filter in a
203
- standalone config store, plus an OPTIONAL two-way ag-grid adapter. Mobile card views consume the
204
- SAME config with no ag-grid at all. agGrid4 is never modified: this is exactly the app-level
205
- column wrapper WRAPPER.md postulates, packaged as a primitive; ag-grid enters only as a type
206
- import + the GridApi handed to grid.attach().
207
- ```
208
- createColumnState({key, columns: ColumnMeta[], def?, saveMs?=300})
209
- -> {columns, api, grid: {attach(api), detach()}}
210
- ColumnMeta = {key, title, short?, icon?, group?, fixed?, defaultVisible?, cardRole?: 'title'|'accent'}
211
- ColumnsConfig= {v, order, visible, width, sort: {key, dir} | null, filter, groups}
212
- api: {getConfig, setConfig, useConfig, onChange, reset, show(k,on), move(order), setSort(s|null),
213
- toggleSort(k), visibleKeys(), getPresent, usePresent, isPresent(k), setPresent(keys|null), getPresentGate, setPresentGate(keys|null), listSource}
214
- ```
215
- Persist + migration (same DNA as createToolbar/createUiSlot): config lives in `memoryGetOrCreate(key)`,
216
- edits mutate it in place + renderBy + memoryMarkDirty, the APP saves via `memoryCache.onDirty`.
217
- `normalize()` is the soft migration - unknown keys dropped, missing columns appended
218
- default-visible, `fixed` pinned to its descriptor index; `v` covers incompatible shape changes.
219
- The persisted object's IDENTITY is the subscription key, so `memoryCache.load()` must run BEFORE the grid
220
- mounts (a grid attached pre-load would apply defaults; QA card 28 gates the grid on `loaded`).
221
-
222
- Semantics that are easy to get wrong:
223
- - STICKY sort: `sort` is independent of visibility and of any UI selection, may point at a hidden
224
- OR grid-absent column, and changes only by an explicit toggle/header click. `readFromGrid` keeps
225
- it when the live grid lacks that column (the grid cannot express a sort by a column it does not
226
- have), so hiding or removing the sorted column never silently resets it.
227
- - Two-way loop protection: store->grid runs under an `applying` flag; grid->store ignores events
228
- while `applying`, with `source=='api'`, or `finished===false` (resize/move fire per animation
229
- frame - only the final shape persists), debounced `saveMs`, and a JSON compare before commit. A
230
- `fixed` column dragged off its slot in the grid: readFromGrid commits, normalize() pins it back,
231
- and if the order changed the adapter re-applies to the grid (snap-back) so the two never disagree.
232
- - Presence (`usePresent/isPresent/setPresent/setPresentGate`): runtime-only, NEVER persisted. It is
233
- the intersection of actual live-grid columns and an optional app-level gate. The adapter maintains
234
- actual presence on attach and on `gridColumnsChanged`; a column removed from the grid (dynamic
235
- columnDefs) keeps its config entry and its menu button (rendered disabled), and when the set changes
236
- the adapter RE-IMPOSES the config so a returning column regains its stored order/width/visibility
237
- (setting columnDefs resets grid order). `setPresentGate(keys|null)` is for stable-schema app modes:
238
- it marks gated-out buttons disabled and hides those grid columns through applyColumnState without
239
- mutating persisted `visible`. No loop: applyColumnState never adds/removes columns. Null actual presence
240
- and null gate together mean all present; if a gate is set, grid-less consumers still see that gate.
241
- - `listSource` = the `{order, visible}` slice exposed as a `UiListSource` (Toolbar's
242
- external-config contract): plug it into `createToolbar({source})` and the toolbar, the icon menu
243
- and the grid all mirror one config. Its setConfig MERGES visible and re-appends via normalize, so
244
- an editor over a SUBSET of columns never drops the rest.
245
- - `filter`/`width` are written by the grid adapter only; `groups` (group key -> enabled
246
- sub-columns) is modelled now; core group UI is still app-owned. QA card 30 demonstrates an app-level
247
- mode button over stable grouped `columnDefs`; `presentGate` hides unavailable leaf columns and keeps their buttons disabled.
248
- `visibleKeys()` additionally gates a grouped column by its group's enabled set.
249
- - Attach from the consumer's `onGridReady` (AgGridTable forwards it over its own wiring) with
250
- `autoSizeColumns={false}` (auto-fit at mount would rewrite stored widths); detach from
251
- `onGridPreDestroyed` - the config outlives the grid (columnBuffer pattern). HMR caveat on the
252
- stand: hot-reload recreates a module-level controller while the live grid stays attached to the
253
- OLD instance, so store->grid needs an F5; in a real app the module runs once.
254
-
255
- High-level kit (`createColumnGrid`, `grid/columnState/columnGrid.tsx`):
256
- ```
257
- createColumnGrid({key, columnDefs?, columns?, data?, getId?, def?, saveMs?, toolbar?, autoSizeOnColumnCountChange?})
258
- -> {state, toolbar, api, grid, tableProps, Table, Menu, Dots, Cards, Toolbar, Settings, View}
259
- useColumnGrid(opts) -> same controller, captured once for component-local setups
260
- ```
261
- - This is the default convenience layer for ordinary app grids that all need the same column menu.
262
- It infers `ColumnMeta` from leaf `ColDef`s (`colId` -> `field`, title from `headerName` -> key)
263
- and merges optional `columns` overrides for `fixed`, `short`, `icon`, `defaultVisible`, `cardRole`,
264
- and group semantics. Unknown explicit overrides are appended, so grid-less/card-only fields still work.
265
- - `Table` and `tableProps()` wrap `AgGridTable` and wire `state.grid.attach/detach` automatically.
266
- Their default `autoSizeColumns=false` is deliberate: the wrapper is for persisted column layout, so
267
- auto-fit must be opt-in. `autoSizeOnColumnCountChange` is a narrower opt-in: it calls
268
- `sizeColumnsToFit()` only when `visibleKeys().length` changes after a columnState edit.
269
- - `View` is a small representation switch (`mode:'table'|'cards'`) with controls
270
- (`'menu'|'dots'|'toolbar'|false|'auto'`). `auto` resolves to the built-in dots overlay for both
271
- table and card mode; the wrapper sets dot max to the full column count unless overridden. `Dots` is
272
- not card-specific; on table mode it edits the same config and the attached grid applies visibility,
273
- order, and sort through columnState. `data`/`getId` can live on the controller defaults or be
274
- passed per `View` render.
275
- - Use raw `createColumnState` plus `createToolbar` when the app needs a custom skin like QA card 30,
276
- a runtime `presentGate`, special group mode buttons, or nonstandard persistence timing.
277
-
278
- Icon menu (ColumnsMenu / MenuStrip, `grid/columnState/ColumnsMenu.tsx`):
279
- ```
280
- <MenuStrip items={MenuStripItem[]} onItem? onMove? move? tail? holdMs?=150 compact? />
281
- MenuStripItem = {key, title, short?, icon?, state: 'on'|'off'|'disabled', marks?, fixed?}
282
- <ColumnsMenu state onItem? marks? tail? onTail? reorder?=true holdMs? compact? />
283
- ```
284
- Two DECOUPLED layers, because "what a click means" is deliberately not the strip's business:
285
- - `MenuStrip` is pure presentation - ordered buttons in three states plus a `marks` adornment
286
- ("on with extras": sub-columns, naming strategies...); it reports clicks (onItem) and
287
- drag-reorders (onMove) but interprets neither. Reusable for ANY multi-state button strip.
288
- `disabled` buttons report no clicks; `fixed` ones do not drag; `tail` = buttons after a divider,
289
- OUTSIDE the reorder list (mode cyclers/actions). Reorder rides `useReorder` (second consumer
290
- after the Toolbar editor) with the same fixed-pinning `move`, so preview == drop.
291
- - `ColumnsMenu` binds it to a columnState: buttons follow config.order (the grid mirror is free -
292
- both read the same config), state = disabled (absent from the live grid) / on / off, default
293
- click = toggle visibility (overridable via `onItem` for multi-state columns), drag = api.move.
294
- It is the compact/presentation surface. For settings-integrated bars that need density,
295
- pseudo-controls, reset, and global Settings reuse, use `createToolbar({source: cs.api.listSource})`
296
- as the canonical richer surface and optionally render a compact `ColumnsMenu` beside it.
297
- - Click-vs-drag guard: a drag that ends on its origin button still fires a browser click; a click
298
- that travelled >4px from mousedown is dropped, so a snapped-back drag never also toggles.
299
- - `compact` = icon-only buttons: the icon, or the first letters of short/title as a text
300
- pseudo-icon (full title in the tooltip), the same rule as `toolbarItemIcon`.
301
-
302
- Mobile (no ag-grid, no storage - the config alone):
303
- ```
304
- <ColumnDots state max?=8 className? style? /> // grid/columnState/ColumnDots.tsx
305
- <CardList<Row> state data getId? renderValue? /> // grid/columnState/CardList.tsx
306
- ```
307
- - `ColumnDots` is a discrete multi-thumb slider on pointer events (react-range cannot change thumb
308
- count): a track of marks (one per column), dots on the shown columns. Gestures use a
309
- dominant-axis test so a horizontal drag never removes and a vertical flick never reorders: drag a
310
- dot along the track = the column takes another (empty) mark; swipe a dot UP = hide (fixed and the
311
- last remaining dot stay); tap an empty mark = show (up to `max`); tap a dot without moving =
312
- select the field; the sort button cycles asc->desc->off on the SELECTED field (sticky - selecting
313
- another dot does not touch it). 44px touch targets.
314
- - `CardList` renders rows as blocks: `visibleKeys()` become the card fields (created/removed live
315
- as dots move), `cardRole:'title'` = the header (else the first visible key), `cardRole:'accent'`
316
- = a badge, the rest are label+value. It sorts by the sticky `config.sort` itself
317
- (numeric/locale comparator) even when the sorted column is hidden.
318
- Styling uses shared classes `.wenayColDots*` / `.wenayCardList*`; runtime geometry remains inline because dot positions and drag transforms are state-derived. QA cards 28 (grid layer + F5 restore), 29 (mobile dots + cards), 30 (toolbar icon menu + grouped sub-column mode button), 31 (Toolbar over columnState.listSource + pseudo-icons), 32 (createColumnGrid wrapper + dots driving table/cards).
319
-
320
- ## Outside / Buttons Compatibility
321
- ```
322
- useOutsideRef(options) -> ref // use useOutside(options).ref / .props
323
- OutsideClickArea // alias of OutsideClickArea
324
-
325
- StyleOtherRow
326
- StyleOtherColumn
327
- ```
328
-
329
- `Button`, `OutsideButton`, `HoverButton`, and `AbsoluteButton` are still direct components rather than hook controllers.
330
-
331
- `Button` persists its open/closed status under `keyForSave` (module-lifetime; same naming as
332
- FloatingWindow/Resizable/RightMenu); the old `keySave` prop is a deprecated alias (`keyForSave`
333
- wins when both are set).
334
-
335
- ## Drag / Resize Low Level
336
- ```
337
- FloatingWindowBase(props) // lower-level react-rnd wrapper
338
- FloatingWindow(props) // canonical floating window component
339
- useFloatingWindowController(options) // headless geometry/stack/drag/resize controller for custom chrome
340
- floatingWindowMap // persisted RND map (ObservableMap)
341
- FloatingWindowUpdate
342
- FloatingWindowProps / FloatingWindowController / FloatingWindowSavedGeometry
343
-
344
- DragBox(props) // delta-drag component; thin adapter over useDraggableApi (A7)
345
- DragArea(props) // @deprecated: unique semantics kept as-is; prefer useDraggableApi/DragBox
346
- FResizableReact(props)
347
- mapResiReact // persisted resize map (ObservableMap)
348
- OutlineDragDemo()
349
- ```
350
-
351
- For new pointer logic, prefer:
352
- ```
353
- const drag = useDraggableApi(...)
354
- <div {...drag.bind} />
355
- ```
356
-
357
- `useDraggable(...)` is kept as the simple hook wrapper; `useDraggableApi(...)` is the controller-style shape.
358
-
359
- Drag primitives after the A7 pass (2026-07-10): `DragBox` is a thin adapter over
360
- `useDraggableApi({holdMs: 0, trackState: false, onMove})` - same observable contract as its old
361
- bespoke loop (immediate start, per-tick imperative `onX`/`onY` with the delta from the press
362
- point, no re-render per move tick, `onStop` only after a real gesture, `last` ref shares the live
363
- position object; pinned by `__test/dragBox.test.tsx`, QA card 35). `DragArea` stays untouched and
364
- `@deprecated`: no consumers, and its semantics are unique (document.body listeners,
365
- `stopPropagation` per move tick, ABSOLUTE coords, no preventDefault) - re-basing it would change
366
- observable behavior; removal only in a breaking version. `useFloatingWindowController`'s header
367
- loop and `StickerMenu` deliberately keep their own loops: viewport clamp + `e.buttons===1` release
368
- recovery + persistence/z-stack, and mount-lifetime listeners + click-vs-drag suppression
369
- respectively, are load-bearing behavior `useDraggableApi` does not model.
370
-
371
- Reorder-by-drag (useReorder, `hooks/useReorder.tsx` - extracted from the Toolbar editor, which is
372
- its first consumer):
373
- ```
374
- useReorder({order, commit, move?, canDrag?, preview?: 'slots'|'measure', holdMs?})
375
- -> {listRef, item(key) -> {props, style?, dragging, active}, dragKey, preview}
376
- ```
377
- A deliberately SMALL reorder for keyed blocks laid out by CSS - the hook never knows the layout
378
- (vertical list, horizontal bar, wrapped grid all work). Semantics:
379
- - DOM order never changes mid-drag: the dragged block follows the pointer via transform, the rest
380
- glide to their preview position (consumer adds the transition on `active && !dragging`), ONE
381
- `commit(next)` on drop, skipped when nothing moved (a plain click is a no-op).
382
- - Targeting = nearest slot center measured at drag START - never against the live layout
383
- (re-measuring moving blocks oscillates at boundaries; designed out). Pointer deltas (viewport
384
- px) are normalized by the container's scale factor (rect.width / offsetWidth), so
385
- `transform: scale` / zoomed ancestors do not skew targeting.
386
- - `move` is the simulated commit: the preview shows exactly `move(order, key, to)` - consumers
387
- with pinning rules (Toolbar's fixed items) pass their own so preview == drop by construction.
388
- - preview 'slots' (default) transforms between start slot centers - exact when blocks are
389
- equal-sized. 'measure' is FLIP: the preview order is applied via CSS `order`, layout offsets
390
- (offsetLeft/Top) are read and reverted in one synchronous pass (never paints), so wrapping with
391
- ANY block sizes previews exactly; requires a flex/grid container; measured once per target
392
- change. Offsets, NEVER getBoundingClientRect: rects include mid-flight TRANSITION values
393
- (style.transform='none' does not stop a running transition within the same synchronous pass),
394
- so rect-based re-measures accumulated the previous preview's shifts on every target change and
395
- the blocks flew apart on target oscillation.
396
- - Events from `input/button/select/textarea/a` never start a drag; mouse is left-button only;
397
- touch works (`touch-action: none` on blocks is the consumer's CSS).
398
- - Non-goals (use a ready-made dnd library instead): nesting, spans/collision packing,
399
- autoscroll. QA card 26 is the live example. Cross-COLUMN moves live in useReorderBoard below.
400
-
401
- Board (useReorderBoard, `hooks/useReorderBoard.tsx` - the columns extension of useReorder):
402
- ```
403
- useReorderBoard({columns: [{key, items}], commit(next), canDrag?, holdMs?,
404
- onDragStart?, onDragMove?, onOverChange?, onDragEnd?})
405
- -> {columnRef(col) -> RefCallback, item(key), dragKey, over: {col, index} | null}
406
- ```
407
- - Columns are plain consumer divs registered via `columnRef(key)` (live callback-ref registry -
408
- ADDING a column is just consumer state + one more div, spliced at ANY position of the columns
409
- array incl. the middle); a column's children must be exactly its items, 1:1, in order. The
410
- column set/geometry is frozen for the duration of one drag.
411
- - Column gravity is pure consumer CSS (`justify-content: flex-start` packs up, `flex-end` packs
412
- down) - the hook never knows it, it measures the real layout. Do NOT use `flex-direction:
413
- column-reverse` (it inverts DOM-vs-visual order; the 1:1 mapping breaks).
414
- - Targeting: nearest column by clamped rect distance, insertion index = how many of that column's
415
- START item centers sit above the dragged center (start geometry only - anti-oscillation, same
416
- rule as useReorder). Preview = simulated commit (movedColumns), so preview == drop.
417
- - Cross-column FLIP, offset-based like useReorder: the dragged block leaves flow via
418
- display:none, survivors get preview CSS `order`, and the landing slot becomes a real
419
- margin gap (draggedHeight + row-gap) on the neighbour - the column's own gravity then decides
420
- who moves aside (a flex-end column slides the blocks ABOVE the slot up). Apply-read-revert in
421
- one synchronous pass, cached per target slot.
422
- - Callbacks: onDragStart (grab), onDragMove (every move, pointer delta in local px),
423
- onOverChange (only when the target slot changes; compare prev.col != over.col for column
424
- crossings), onDragEnd (final slot + committed flag; a plain click is committed=false).
425
- `over`/`dragKey` are also returned reactively for render-time styling (column highlight).
426
- - QA card 27 is the live example (5 columns, mixed gravity, empty column, add-column).
427
-
428
- ## Grid Row Utilities
429
- ```
430
- applyGridRows(params)
431
- ```
432
-
433
- Use `agGrid4` for new tables:
434
- ```
435
- createGridBuffer()
436
- useAgGrid()
437
- AgGridTable
438
- createColumnBuffer()
439
- numericComparator()
440
- colDefCentered
441
- colDefWrap
442
- ```
443
-
444
- Important agGrid4 contracts:
445
- ```
446
- mirror mode: buffer owns row set; sync can add/update/remove grid rows.
447
- overlay mode: rowData owns row set; sync updates only existing grid rows.
448
- clean(): clears buffer; mirror also removes grid rows, overlay does not remove declarative rows.
449
- flush(): React/controller method that calls ag-grid flushAsyncTransactions().
450
- ```
451
-
452
- Plain declarative `rowData` should preserve AgGridReact defaults unless the caller provides `getRowId`.
453
- Buffered paths need a stable `getId`.
454
-
455
- Dynamic columns:
456
- ```
457
- createColumnBuffer().api.setNames(names) // exact set, dedupe preserving order
458
- createColumnBuffer().api.apply() // replay last attached apply callback
459
- createColumnBuffer().control.attach(api, {apply})
460
- createColumnBuffer().control.detach() // keep names
461
- ```
462
-
463
- The primitive must not contain product group names, base `columnDefs`, visibility rules, or app naming policy.
464
-
465
- ## Params / Generated Editors
466
- ```
467
- ParamsEditor({params, onChange, onExpand?, expandStatus?, expandStatusLvl?})
468
- useParamsEditorController({params, onChange, onExpand?})
469
- ParamsEdit({params, onSave}) // canonical compact editor
470
- ParamsArrayEdit({params, onSave}) // array params editor
471
- ParamRow({param, onClick, type?})
472
- ParamLabelContent(name)
473
- ParamToggleLabel(type, name)
474
- ```
475
-
476
- `ParamsEditor` receives wenay-common2 `Params.IParamsExpandableReadonly` shape. `useParamsEditorController` owns the mutable draft clone, immediate/delayed notify, expand callback, and timer cleanup for custom renderers; `ParamsEditor` remains the compatibility visual wrapper.
477
-
478
- ## Menu Low Level
479
- ```
480
- Menu({data, coordinate?, zIndex?, className?, menu?, menuElement?})
481
- MenuElement
482
- MenuProgress
483
- MenuItemStrict / MenuItem
484
-
485
- contextMenu.openAt(eventOrPoint, items, {source?, layerId?}) -> boolean
486
- contextMenu.openAtPoint({x, y}, items, {source?, layerId?}) -> boolean
487
- contextMenu.close()
488
- contextMenu.getState() -> {open, items, point, source?, layerId?, seq}
489
- contextMenu.stats.getSnapshot() -> {openAt, openAtPoint, legacyLayer, close, replace, empty, sources, layers, actionTotals, actions}
490
- contextMenu.stats.reset()
491
- contextMenu.stats.onChange(cb) -> off
492
- <contextMenu.Layer zIndex? statusOn? other? className?>...</contextMenu.Layer>
493
- contextMenu.map // legacy queue consumed by Layer; prefer openAt
494
- createContextMenu({name?}) // custom isolated instance with its own state and stats
495
- createRightClickMenu() // lower-level legacy right-click factory
496
-
497
- DropdownMenu({elements, trigger?, classNames?, styles?, style?, position?, verticalPosition?, keyForSave?})
498
- useRightMenuController({elements, style?, styles?, position?, verticalPosition?, keyForSave?})
499
- createRightMenuController()
500
- mapRightMenu // persisted floating-menu state (ObservableMap, RightMenuStore)
501
- MenuRightPosition / MenuRightVerticalPosition / MenuRightSavedState / MenuRightRenderProps
502
- MenuRightTrigger / MenuRightClassNames / MenuRightStyles / RightMenuController
503
- StickerMenu // components/Menu re-export
504
- ```
505
-
506
- Prefer `contextMenu.openAt(e, items, {source?})` for new right-click integrations. `contextMenu.map` remains for older callers that queue items before Layer handles the right-click, and stays supported through `1.x`; it should not be the primary API in new code. `contextMenu.stats` is local in-memory diagnostics, not hidden analytics: it counts direct `openAt`, `openAtPoint`, legacy Layer queued opens, empty opens, close/replace, source/layer usage, aggregate action outcomes, and keyed action outcomes. It deliberately does not persist, send network requests, or store arbitrary item labels. Per-action stats require explicit `MenuItemStrict.actionKey`; unkeyed actions update `actionTotals` only.
507
-
508
- `Menu` does not mutate `item.status`. Open/hover state is an internal active index; `status` remains a seed/compatibility value, and custom item renderers receive a view item whose `status` mirrors the current open state.
509
-
510
- `DropdownMenu` is a floating action menu, not the main context-menu primitive. Its trigger glyph/content and visual classes/styles are caller-owned through `trigger`, `classNames`, and `styles`; the default still renders the old hamburger glyph and CSS classes. `useRightMenuController` is the hook/controller layer behind it for custom views; do not duplicate hover/fixed/submenu timers in product code.
511
-
512
-
513
- ## Observe / Listen React Hooks
514
- Canonical React adapter names:
515
- ```
516
- useStoreNode(node, {mode?, fallback?, drain?, key?})
517
- useStoreKeys(node, {fallback?, drain?, key?}) // object-shape changes: add/delete keys
518
- useStoreChangedPaths(changedPathsListen, {initial?, key?})
519
- useStoreEach(store, cb, {enabled?=true}) // store.each() wrapper: cb(key, value, ctx) per CHANGED top-level key; undefined = deleted; root replace expands per key; renders nothing itself
520
- useStoreSelect(selection, {fallback?, drain?, key?})
521
- useStoreMirror(remote, initial, {mask, current?=true, drain?, key?, auto?=true, partial?, onError?})
522
- useListenEffect(listen, cb, {current?, key?})
523
- useListenArgs(listen, {initial?, current?, key?})
524
- useListenValue(listen, {initial?, current?, key?, map?})
525
- ```
526
-
527
- These hooks are intentionally in `wenay-react2`, not in `wenay-common2`.
528
- Do not add React hooks for every helper exposed by `wenay-common2`; read the installed common2 package docs when needed, and document only the React-facing contract here. Hooks are useful only around subscriptions, lifecycle, or external resources.
529
- Network sync stays explicit through `useStoreMirror`; reading a node must not secretly start RPC/fetch/WebSocket work. When `remote.changedPaths` exists and `partial !== false`, mirror uses common2 partial sync; otherwise it falls back to `remote.changed -> get(mask)`. `initial` seeds the mirror only. Structurally equal inline masks are stable, because StoreMask is expected to be a small plain object/tree.
530
-
531
- QA stand coverage:
532
- ```
533
- /__qa/observe-store/get // HTTP snapshot/mask read
534
- /__qa/observe-store/mutate // HTTP server mutation, including deep leaf and deep key add/delete
535
- /__qa/observe-store/events // SSE changed stream
536
- /__qa/observe-store/events-paths // SSE changedPaths stream
537
- ```
538
-
539
- ## Replay React Hooks (details)
540
- `src/common/src/hooks/useReplay.ts`. Client side of the wenay-common2 Replay stack only.
541
- ```
542
- useReplaySubscribe(remote, cb, {since?, keepSeq?=true, enabled?=true, onSeq?, onError?, staleMs?, onStale?, policy?, hint?})
543
- -> {ready, error, stale, seq(), lastTs(), restart(since?)}
544
- useReplayRouteSubscribe(remote, cb, {since?, keepSeq?=true, enabled?=true, label?, onSeq?, onError?, onRoute?, policy?, hint?})
545
- -> {ready, error, route, switching, seq(), label(), active(), switchRoute(nextRemote, opts?)}
546
- useStoreReplaySync(store, remote, sameOpts) -> same controller // Observe.syncStoreReplay wrapper
547
- useStoreReplayRouteSync(store, remote, routeOpts) -> route controller // Observe.syncStoreReplayRoute wrapper
548
- useStoreReplayMirror(remote, initial, sameOpts) -> controller & {store} // creates the mirror store in a ref
549
- useStoreReplayRouteMirror(remote, initial, routeOpts) -> route controller & {store}
550
- useStoreReplayEach(remote, cb, sameOpts & {initial?, drain?}) -> controller & {store} // per-key fold: Observe.syncStoreReplayEach counterpart
551
- useReplayFrame(remote, cb, {intervalMs?=300, since?, keepSeq?=true, enabled?=true, hint?, onSeq?, onError?})
552
- -> {ready, error, seq(), pull(hint?), restart(since?)} // pull-at-own-pace over remote.frame()
553
- useReplayHistory(history, apply, {head?, reset?, tickMs?=300, autoPlay?=true})
554
- -> {live, seq, head, pause(), play(), seek({seq?|ts?})}
555
- ```
556
- Semantics that are easy to get wrong:
557
- - `cb`/`apply` go through refs: new function identity does NOT resubscribe. Resubscribe identity is `[remote, enabled, epoch, staleMs, policy]` — changing `staleMs` resubscribes (it is subscribe-time config in common2; under keepSeq the reconnect is a journal tail, so it is cheap); `policy` picks the wire surface (line vs frameLine), so it resubscribes too. `onStale` goes through a ref like `cb`.
558
- - Route hand-off hooks (`useReplayRouteSubscribe`, `useStoreReplayRouteSync`, `useStoreReplayRouteMirror`) wrap common2 `Replay.replayRouteSubscribe` / `Observe.syncStoreReplayRoute`: `switchRoute(nextRemote, {label?, since?, reset?, policy?, hint?})` keeps the old route live, catches up the replacement from the last delivered `seq`, then closes the old route. Failed replacement leaves the old route active and surfaces `error` / `route.phase == "error"`. Changing the `remote` prop is still treated as a fresh subscription boundary; no-gap promotion/demotion is explicit through `switchRoute()`. common2 1.0.65 route handles expose `seq()`, `label()`, and `active()`, but not `isStale()` / `lastTs()`, so route hooks deliberately do not promise freshness yet.
559
- - Lag policy / hint (frame model, common2 rev2): `policy: 'frame'` rides the server's `frameLine` when the remote has one — on lag the server drops for THIS client and recovers via the line's `frame` lambda (mini-frame); without a frameLine (old server, in-proc `exposeReplay`) common2 silently degrades to `'queue'`, so an in-proc QA of `'frame'` proves nothing — the wire test lives in common2 (`replay/rpc-auto.test.ts`). `hint` is opaque to the transport and goes through a ref: in `useReplaySubscribe` it is captured at subscribe time (used for the catch-up `frame()` call); in `useReplayFrame` it is read on EVERY pull, so a new identity is never missed and never resubscribes.
560
- - `useReplayFrame` is the pull path: no live socket subscription at all; a timer calls `remote.frame(seq, hint)` and folds envelopes (seq-ascending, seen seq skipped — a keyframe recovery is just an envelope). Fresh start (no `since`) mirrors `replaySubscribe`'s catch-up for since<0: `keyframe()` is polled until the line has one (`frame(-1)` has no tail to return and THROWS on a still-empty line — a mount racing the producer's first event must not stick an error); a sacred line therefore NEEDS an explicit `since` (0 = full tail) — omitted, it waits forever. Overlapping pulls are never issued (a slow `frame()` skips ticks). Errors are LOUD and sticky: a reject (network, sacred line evicted past our seq, remote without `frame()`) stops the timer and sets `error` until `restart()` — deliberately no tail fallback here, that is `replaySubscribe`'s job. Freshness (`staleMs`) does not apply (the consumer owns the cadence).
561
- - Freshness (`staleMs`): detection is 100% wenay-common2's watchdog (`ReplaySubscribeOpts.staleMs/onStale`, edge-triggered); the hook only mirrors the edges into `stale` state, so a fresh high-frequency line causes zero extra renders. `lastTs()` is a plain getter like `seq()` (producer ts of the last delivered event, 0 before the first delivery / while unsubscribed). Without `staleMs`: no watchdog, no state updates, `stale` stays `false`.
562
- - Freshness on resubscribe/restart: the hook does NOT reset `stale` to `false` at effect start — it re-syncs from common2 after catch-up (mirrors the `onStale` edge fired during catch-up, plus `isStale()` once `ready` resolves), so a snapshot/tail carrying an old producer ts is stale from the first paint with no fresh-flicker. Caveat: common2's in-proc `keyframe()` stamps `ts` at request time, so a brand-new client on a stalled in-proc line reads as fresh at delivery and flips stale after `staleMs` via the arrival-gap watchdog; old-ts detection kicks in when the tail/keyframe actually carries producer timestamps (real relays, archives).
563
- - `useReplayHistory` is archive playback — staleness intentionally does not apply there.
564
- - `keepSeq`: within one mount, a resubscribe (StrictMode double effect, `enabled` toggle, `restart()`) reconnects with `{since: lastSeq}` -> journal tail. A NEW `remote` identity always resets seq (another line's seq is meaningless). A full unmount loses the refs: persist the position in the parent via `onSeq` and pass it back as `since` (QA card 23, client A).
565
- - `seq()` and `useReplayHistory`'s interval-driven `seq/head` keep high-frequency lines from re-rendering per event. Frames/ticks should fold into a canvas/ref/store, not useState.
566
- - `useReplayHistory.seek` kills the live subscription imperatively (guarded off, double-call safe), folds `history.at(where)` through `apply` (keyframe + tail; a keyframe fully redefines consumer state, so `reset` is rarely needed), and freezes. `play()` resubscribes `{since: pos}` -> archive tail -> live handover.
567
- - `useStoreReplayEach` composes existing pieces instead of calling `Observe.syncStoreReplayEach` (which builds a FRESH store per call): the mirror lives in a ref (like `useStoreReplayMirror`), `store.each()` is subscribed in an effect declared BEFORE the sync effect (hook-call order guarantees the per-key subscriber exists when the keyframe applies — the expansion is never missed), then `useStoreReplaySync` drives the wire. Consequences: within one mount every resubscribe (StrictMode, `restart()`, `enabled` toggle, `staleMs`/`policy` change) reconnects by tail ON TOP of kept state and the consumer sees only the diff — no snapshot/`initial` dance (the library one-call needs `{since, initial: snapshot}` for that). After a FULL unmount pass `{since: prev.seq(), initial: prev.store.snapshot()}` like the library contract. `drain` is creation-time (it goes to `createStore`); changing it later does nothing. The fold target must live outside React state (ref/Map/grid api) — the hook renders nothing; `controller.store` is a normal store for `useStoreNode`/`useStoreKeys` extras.
568
- - Server primitives (`conflateReplay` per connection, `archiveReplay`) intentionally have no hooks: they live where the RPC server is built.
569
- - Route coordinator (common2 1.0.67 `Replay.createRouteCoordinator`): the coordinator owns promotion/fallback/shadow via policy hooks; consumers subscribe on a pair `link`, whose handle matches our route hooks (`ready, seq(), label(), active()`) minus `switchRoute` — the transport switch is invisible by the seq contract. Our route hooks (`useReplayRouteSubscribe` etc.) remain the manual-`switchRoute` wrapper over `replayRouteSubscribe`; they do NOT sit on top of a coordinator. React surface for coordinator links (subscribe hook + route-state/`promoteDirect` controls for ops UI) is a target-backlog item — do not bend the existing route hooks to accept links implicitly.
570
- - WebRTC direct path (common2 1.0.68): `createWebRtcConnector({port, rtc, ...})` is the coordinator's direct `RouteConnector`; `rtc` is an injected factory and in the browser that injection (`() => new RTCPeerConnection(cfg)`) belongs to the app/React side — common2 stays lib.dom-free. Signaling rides the EXISTING rpc socket (`createSignalHub` port exposed by `createRpcServerAuto`), so no new connection management appears in React; channel death / revoke surfaces as loud `onError` on the line (never silence), which the existing hooks already map to `error`. Nothing here needs a hook by itself — it matters as the direct transport under a future adapter (target backlog).
571
- - Peer SDK (common2 1.0.69 `Peer` / `wenay-common2/peer`): the one-call layer over rpc + store + replay + route coordinator — `createPeerClient({remote, account, initial, rtc?})` publishes the own store as a patch line and `peer(account)` returns a live mirrored store + route control; relay and direct share the OWNER's seq space (`createPatchRelayJournal`), so hand-offs are plain seq resumes, no keyframe reset. This is the intended React adoption surface (a `usePeer` adapter is the target-backlog item, superseding the raw coordinator-link hook idea): the mirrored store already plugs into `useStoreNode`/`useStoreKeys` as-is. 1.0.70 ships the oracle suites + `demo/` stand in the package — living examples per subsystem (`replay/peer-sdk.test.ts` is the Peer contract).
572
- - Media capture (common2 1.0.66 `Media` namespace / `wenay-common2/media`): CONSUMPTION needs no new hooks — `Media.createAudioSource` / `Media.createVideoSource` return `[emit, listen] & control`, with `replay:true` the `listen` is a normal replay line, so `useReplaySubscribe` / `useReplayFrame` cover it; frames are one `Uint8Array` (40-byte fixed header + raw payload, `Media.decodeMediaFrame`) and follow the existing fold-outside-React rule (canvas/AudioContext via ref). The capture `control` side (`start()` resolving to `'idle'|'requesting'|'live'|'denied'|'no-device'|'error'`, `stop()`, `setDevice`, `listDevices`, stats) is a permission/device/lifecycle state machine — exactly hook-shaped; a `useMediaSource` capture hook + QA card is a recorded target goal (supersedes the earlier "wrap it app-locally" stance). Backpressure defaults live in common2 (audio = lossless queue, video `replay:true` = keep-latest frame recovery); the client-side knobs are the same `policy`/`hint` as any replay line.
573
-
574
- QA cards 23/24/25/26 (`testUseReact/replayVideo.tsx`, all in-proc): synthetic 10fps jpeg-frame producer on `Replay.replayListen({history, current})`; client A = direct `exposeReplay` remote; client B = simulated slow wire (1 envelope per rateMs) behind `conflateReplay({pending: () => buf.length, highWater: 4, lowWater: 1, keyOf: () => "frame"})`; client C = `archiveReplay` + `openHistory` scrubber; client D = freshness (`staleMs: 2000`, `React.memo` + no tick, mounted inside a local `<StrictMode>`; the flat renders counter under growing frames is the no-per-event-render proof; "stall producer" toggles the emit interval, "new client" remounts by key for the stalled-mount case; card 24 has the same via `staleMs: 2500` on the mirror); client E = pull path (`useReplayFrame` over the direct remote with a wrapped counting `frame()`, pace switch 250ms/1s/3s keeps seq). `window.__replayVideoDemo` is exposed for debugging (wire.setRateMs, stats). Node-verified: slow wire delivered 12/36 envelopes yet converged to the last frame with bounded buffer (coalesced tail recovery); `syncStoreReplay` off() freezes the mirror and `{since}` resubscribe catches up by tail. Card 25 = per-key feed (`useStoreReplayEach` over `exposeStoreReplay`): a dict-of-rows store, producer touches ONE random row per tick, the fold target is a plain Map with per-row cb counters — only the mutated row's counter grows, keyframe/`replace` are the only whole-table expansions, delete arrives as `(key, undefined)`. Card 26 = route hand-off (`useReplayRouteSubscribe` over the same video line): one canvas starts on relay, switches direct/relay by `switchRoute`, and failed replacement keeps the previous route alive. Browser QA of throttling-sensitive behavior needs a VISIBLE tab: hidden-tab timer/effect throttling stalls the producer and delays passive effects (known stand caveat).
575
-
576
- ## Logs
577
- Frequent global logger:
578
- ```
579
- getLogsApi({limit?, limitPer, varMin?})
580
- createLogsController({options, state?, onFullChange?, onMiniChange?, onSettingsChange?})
581
- createLogsControllerState({full?, mini?, settings?})
582
- logsApi
583
- PageLogs({update?})
584
- useMessageEventLogsController({maxVisible?})
585
- MessageEventLogsView({controller, zIndex?, className?, style?})
586
- MessageEventLogCard({logs})
587
- MessageEventLogs({zIndex?}) // compatibility wrapper
588
- LogsPage({update?})
589
- useMiniLogsTable({data, onClick?, columnDefs?, defaultColDef?})
590
- MiniLogsView({controller})
591
- MiniLogsTable({data, onClick?, columnDefs?, defaultColDef?})
592
- MiniLogs({data, onClick?}) // compatibility wrapper
593
- ```
594
-
595
- React-context logger:
596
- ```
597
- LogsProvider
598
- useLogsContext()
599
- useLogsTableController()
600
- LogsTable()
601
- useLogsNotificationsController()
602
- LogsNotifications()
603
- LogsSettings()
604
- MainPage()
605
- ```
606
-
607
- The context logger is a larger UI surface; the global `logsApi` is still the shorter integration point. `createLogsController` is the headless layer for append/limit/settings state; `useMessageEventLogsController` owns the global notification queue/timers/settings, while `MessageEventLogsView` and `MessageEventLogCard` own rendering. `PageLogs`, `MessageEventLogs`, and `LogsPage` remain compatibility UI wrappers. `useLogsTableController` and `useLogsNotificationsController` expose the provider-local table/notification state while `LogsTable` and `LogsNotifications` keep the visual wrappers. Shared logger chrome lives in `src/common/src/logs/logStyles.ts` and is themed through `--logs-*` tokens.
608
-
609
- Full-page table controller (`useLogsPageTable` -> `LogsPageTableController`): the last logs table
610
- moved to the controller pattern. Semantics that matter: the grid receives a MOUNT-TIME snapshot of
611
- the accumulated log map once (`useState` initializer) and afterwards lives on
612
- `applyTransactionAsync` appends from the mini feed - re-renders do not re-feed `rowData`, so row
613
- identity is per-append copy exactly as before. The importance filter is ONE method
614
- (`applyImportanceFilter(min?)`: set -> `setFilterModel` greaterThanOrEqual on `var`, unset ->
615
- `destroyFilter`), used by both the settings subscription and `onGridReady` (which skips the
616
- destroy branch - a fresh grid has no filter). Both data subscriptions are `updateBy(obj, cb)`
617
- WITH a callback: they run imperatively on `renderBy` and never re-render the component.
618
- `gridProps` is one stable memo - spread it into `AgGridTable`; `fit()` is exposed for the legacy
619
- `update` prop, which `PageLogs` still honors. QA card 9.
620
-
621
- ## Cache / Memory / Browser Utilities
622
- ```
623
- browserCacheStorage
624
- localStorageCache
625
- restoreDates(obj)
626
- createCacheMapWithStorage(entries, save)
627
- createCacheMap(entries)
628
-
629
- ObservableMap<K,V> extends Map // set/delete/clear announce themselves; touch(key?)
630
- .onChange(cb) -> off // announces an in-place mutation; MapChangeListener<K>
631
- memoryCache.onDirty(cb) -> off // instance dirty channel (coalesced, async); DirtyListener
632
- memoryCache.isDirty()
633
- memoryCache.markDirty(scope?, key?) // manual announce, for plain-Map entries only
634
-
635
- memorySet(key, data)
636
- memoryGet(key)
637
- memoryGetOrCreate(key, def, options?)
638
- memoryGetById(key, def, id)
639
- memoryUpdate(key, mutate)
640
- memoryMarkDirty(key)
641
- createSearchHistory({key, max?})
642
- deepMergeWithMap(target, source)
643
- structEqual(a, b) // deep equality for plain data trees; JSON.stringify-idiom
644
- // tolerances (undefined props absent, NaN==NaN) WITHOUT
645
- // its key-order sensitivity; not for Maps/Sets/cycles
646
- memoryCache
647
- memoryMaps
648
-
649
- ArrayPromise({arr, catchF?, thenF?})
650
- PageVisibilityProvider
651
- PageVisibilityContext
652
- setAutoStepForElement(input, {minStep?, maxStep?})
653
-
654
- useCacheMapPersistence(cache: CacheMap, delay?=300) -> {isDirty(), flush(), save(), reload()}
655
- ```
656
-
657
- Layering (2026-07 architecture pass): the persisted maps (`floatingWindowMap`, `mapResiReact`,
658
- `mapRightMenu`) are DECLARED in the utils leaf `utils/persistedMaps.ts` and re-exported by their
659
- owning components (FloatingWindow/Resizable/RightMenuStore) - importing `memoryStore`/`memoryCache`
660
- no longer pulls react-rnd or the Menu tree; only `import type` lines point upward (erased at build).
661
- Shared order helper `utils/fixedOrder.ts` (`pinFixedOrder`, `movedOrderWithFixed`) is the single
662
- implementation of the "fixed entries pin to descriptor index" invariant used by columnState,
663
- ColumnsMenu, and Toolbar (it used to be four inline copies that had to stay byte-identical).
664
-
665
- Cache helpers are process/browser storage utilities, not React state management. The one React
666
- entry here is `useCacheMapPersistence` - the documented app contract (`load()` on mount +
667
- `onDirty -> saveDebounced(delay)`) as a hook. Scope is deliberately exactly that: the
668
- pagehide/visibilitychange flush backstops stay app-side (they are page-level policy, not
669
- per-component). Effect identity is `[cache, delay]`; the returned methods are pass-throughs and
670
- `reload()` is an alias of `load()` - a repeated load MERGES storage on top of the current maps
671
- (addDataToMap semantics), it is not a reset. QA cards 20/21/25 share one `memoryCache`, so
672
- several mounted hooks coexist: load() is idempotent-enough (merge) and extra onDirty
673
- subscribers just debounce the same save.
674
-
675
- Dirty/save contract: the dirty signal originates in the data layer. The persisted maps are
676
- `ObservableMap`s, so `set/delete/clear` announce themselves; in-place mutations of stored
677
- objects are invisible to map methods and are announced with `map.touch(key)` at the commit
678
- points (FloatingWindow drag/resize stop, FResizableReact resize stop, createUiSlot.setPlace) or,
679
- app-side, with `memoryUpdate(key, mutate)` / `memoryMarkDirty(key)`. `createCacheMapWithStorage`
680
- subscribes to the ObservableMaps it owns - the channel is per instance, there is no global
681
- bus; plain Maps in `arr` stay silent (announce those via `memoryCache.markDirty`). Announcements
682
- never save anything: scope/key are event metadata, WHAT gets written is decided by the
683
- save-side serialized-snapshot diff, so a missed announcement degrades to "saved later" and
684
- an extra one to a no-op write. `isDirty()` is set synchronously; `onDirty` delivery is
685
- coalesced through a microtask (safe to mutate maps inside render/init paths; not throttled
686
- in background tabs the way timers are). `memoryCache.load()` ignores its own map events while
687
- loading and resets dirty after; a save cycle waits out an in-flight load() (a save racing
688
- a load could overwrite storage with in-memory defaults) and resets the flag at its START
689
- so a change arriving mid-write survives. Caveat: `flush()` is async - on pagehide
690
- localStorage writes usually complete but are not guaranteed (Cache API is not); prefer
691
- visibilitychange->hidden as the primary final save.
692
-
693
- ## Resize Observer
694
- ```
695
- CResizeObserver
696
- setResizeableElement(el)
697
- removeResizeableElement(el)
698
- ObserveID
699
-
700
- useResizeObserver<T>(onResize) -> {ref, element()}
701
- useElementSize<T>() -> {ref, element(), width, height, getSize()}
702
- ```
703
-
704
- Use the class/imperative pair only when a component must participate in the shared resize
705
- observer map. The hooks are the React-shaped entry over the SAME module singleton (one native
706
- `ResizeObserver` per app): `useResizeObserver` returns a callback ref (attach/detach re-observes;
707
- React null-call on unmount unsubscribes) and takes `onResize` through a ref - new identity
708
- neither resubscribes nor is missed. The native observer fires once right after `observe`, so the
709
- first measurement arrives without an explicit initial read. `useElementSize` folds that into
710
- rounded, equality-guarded `{width, height}` state (a no-op resize does not re-render) plus a live
711
- `getSize()` getter (exact floats, no render wait). `setResizeableElement`'s auto-shrink path is
712
- untouched and still class-driven (ParamsEditor inputs). QA card 19 drives both hooks.
713
-
714
- ## Styles
715
- ```
716
- tokens // TS mirror for inline styles and z-index
717
- GridStyleDefault()
718
- StyleGridDefault
719
- StyleCSSHeadGridEdit(name, rules)
720
- StyleCSSHeadGrid()
721
- AgGridClassRule<T>
722
- ```
723
-
724
- Style entry points:
725
- - `src/style/tokens.css` - CSS custom properties shipped to consumers.
726
- - `src/common/src/styles/tokens.ts` - TS mirror for inline styles, modal z-index, and ag-grid theme params. Values must stay aligned with `tokens.css`.
727
- - `src/style/style.css` - shared component classes and token consumers.
728
- - `src/style/menuRight.css` - right-menu classes and outline-demo CSS.
729
- - `src/common/src/styles/styleGrid.ts` and `src/common/src/grid/agGrid4/theme.ts` - ag-grid theme setup from `tokens.grid`.
730
- - QA stand: `npm run testReact -- --host 127.0.0.1 --port 3002`, entry `src/common/testUseReact/qa.tsx`.
731
-
732
- Current tokenized prefixes:
733
- - `--color-*` for base palette.
734
- - `--menu-*` for mouse context-menu and right-menu visuals.
735
- - `--wnd-*` for `FloatingWindow` chrome.
736
- - `--dlg-*` for `SettingsDialog` chrome.
737
- - `--tb-*` for toolbar chrome.
738
- - `--logs-*` for global/context logger chrome.
739
- - `--cols-menu-*` for compact `ColumnsMenu/MenuStrip` chrome.
740
- - `--cols-dots-*` for `ColumnDots` chrome.
741
- - `--cols-card-*` for `CardList` chrome.
742
- - `--wenay-z-modal` for modal/overlay stacking.
743
-
744
- Normalization rule: new shared CSS should first try an existing token. Add a new token only when a value is reused by a shared primitive or is expected to be theme-overridden by apps. One-off app/demo styles should stay in the demo/app wrapper, not in library tokens. Do not delete a default style/class without a replacement class/token path and changelog entry; visually broken defaults are treated as a compatibility break.
745
-
746
- Open normalization candidates:
747
- - `src/style/style.css`: `.msTradeAlt`, `.msTradeActive`, `.newButtonSimple`, `.toIndicatorMenuButton:hover`, submit-button green, and several toolbar row hover/drag literals still use raw colors.
748
- - `src/common/src/grid/columnState/*`: compact menu/dots/card visuals now use `.wenayColumnGrid*`, `.wenayColsMenu*`, `.wenayColDots*`, `.wenayCardList*` plus `--cols-grid-*`, `--cols-menu-*`, `--cols-dots-*`, and `--cols-card-*`; further changes here should be visual QA only, not a new default palette.
749
- - `src/common/src/components/ParamsEditor.tsx` and `src/common/src/components/Input.tsx`: if these stay public primitives, define default class/token contracts instead of component-owned visual styling.
750
- - `src/common/src/styles/commentaryStyles.css`: standalone `.commentary` CSS is not imported by the root style bundle; either import/tokenize it if still used, or mark it as a local component concern.
751
-
752
- Recently normalized: mouse context-menu item colors through `--menu-*`, `--menu-outline-color` for `OutlineDragDemo`, `--logs-*` for logger chrome, `--dlg-scrim` in `ModalProvider`, compact `ColumnsMenu/MenuStrip` visuals through `.wenayColsMenu*` / `--cols-menu-*`, card-29 mobile primitives through `--cols-dots-*` / `--cols-card-*`, and createColumnGrid overlay through `.wenayColumnGrid*` / `--cols-grid-*`.
753
-
754
- ## Cleanup Inventory
755
-
756
- Do not delete a public export just because it looks unused inside this repo. External apps may import it. For cleanup, first move an item into this inventory, then decide in a separate breaking version whether it remains public, moves to a demo namespace, or is removed.
757
-
758
- Suspicious but still public:
759
- - `OutlineDragDemo`, `RightMenuDemo`, `ChartDemo` - demo/test-style exports. They are useful for the QA stand, but look like demo surface rather than core API.
760
- - `StickerMenu` - exported from `components/Menu`; visually app-specific and should probably become an app wrapper or be documented as an example component.
761
- - `logsApi` global logger and the context logger (`LogsProvider`, `LogsTable`, `LogsNotifications`, `LogsSettings`, `MainPage`) overlap. Keep both for now; document one as the short integration path and the other as the larger UI surface.
762
- - Chart engine primitives (`DataSet`, `Panel`, `Renderer`, `Interaction`, `ChartEngine`, etc.) are very low-level. They are public through `chartEngineReact.tsx`; product apps should wrap them before use.
763
- - `src/common/src/myChart/chartEngine/chartEngine.ts` is a reference copy next to the public React engine. It is not exported from root; treat it as suspicious maintenance debt, not as public API.
764
- - `StyleCSSHeadGridEdit` and `StyleCSSHeadGrid` mutate `<head>` directly. They are exported and can have consumers, but new grid styling should prefer ag-grid theme params and tokens.
765
-
766
- Added by the 2026-07-09 architecture audit (evidence in the audit report / `doc/target/my.md`):
767
- - `menuR.tsx` (`createRightClickMenu` / `MenuR`) - CONFIRMED dead: no runtime consumer anywhere (the test that supposedly covers it imports `RightMenu.tsx`); re-exported twice from `api.tsx` (`export *` + `kit.menu.rightClick`). Candidate for removal in a deliberate breaking version; its gesture code duplicates `menuMouse.tsx:342-380`.
768
- - `logsContext.tsx` - a full PARALLEL logs stack (own `LogEntry` shape divergent from `logsController`, raw unguarded `localStorage`, own settings/notifications/table) exported from the barrel next to `logs.tsx`. Decide: deprecate or converge; new code should use `logsApi`/`createLogsController`.
769
- - `MyChartEngine` - a hardcoded DEMO (random data + setInterval inside `useEffect`, no props but `style`) exported as public API from `chartEngineReact.tsx`; `generateIncrementalData` (Math.random demo util) is public too. Should move to demo namespace or gain a real props contract.
770
- - ~~`getLogsApi<T>()` shared module state~~ FIXED (A2, v1.0.41): every call now builds its own map/mini/settings (optional `settingsKey` persists per instance); the global `logsApi` explicitly injects the legacy shared state.
771
- - ~~`FloatingWindow` `onCLickClose` typo~~ ALIASED (A10, v1.0.42): `onClickClose` added, typo prop is a deprecated alias; REMOVAL of the alias still needs a breaking pass.
772
- - `DragArea` - `@deprecated` (A7, v1.0.42): no consumers; unique semantics kept as-is (see Drag / Resize Low Level). Removal in a deliberate breaking version.
773
- - `Button` `keySave` prop - deprecated alias of `keyForSave` (A10, v1.0.42); removal in a breaking pass.
774
- - `contextMenu` raw `map`/legacy-queue path is test-only-live (populated only by `contextMenuStats.test.tsx`); the `bb`/`map.clear()` reopening invariant itself is intentional and stays.
775
-
776
- ## Charts
777
- Canvas chart:
778
- ```
779
- createChartCanvas(config) -> IChartCanvas
780
- ChartDemo()
781
- ```
782
-
783
- Chart engine:
784
- ```
785
- createDataSet(params)
786
- createDataModel()
787
- createPanelManager()
788
- createRenderer()
789
- createInteraction(...)
790
- createChartEngine(canvas)
791
- generateIncrementalData(...)
792
- MyChartEngine
793
- ```
794
-
795
- The chart engine exports many internal interfaces (`DataPoint`, `DataSet`, `Panel`, `Renderer`, `Transform`,
796
- `Interaction`, `ChartEngine`). Treat them as low-level until an app wrapper fixes product-level behavior.
797
-
798
- ## Deprecated Naming Smells
799
- ```
800
- Get* // usually a factory from older style; prefer create* or use*
801
- *FuncJSX // imperative JSX store; prefer React context/controller
802
- *2 / *3 // version suffix; document the intended canonical one in brief
803
- removed old grid update names -> applyGridRows / agGrid4
804
- ```
805
-
806
- Do not add new generic utilities with app words in their signature. For example, a column primitive should accept
807
- `names` and `apply`, while a product wrapper decides group ids, column ids, and labels.
1
+ # wenay-react2 - EXTENDED / rare surface
2
+
3
+ > Everyday API lives in **`wenay-react2.md`**.
4
+ > This file lists low-level primitives and migration notes.
5
+ > Root import: `import { ... } from "wenay-react2"`.
6
+
7
+ ## Migration Rule
8
+ ```
9
+ New code teaches and imports the short canonical surface:
10
+ useModal(), useOutside(), useDraggableApi(), useReorder(), useReorderBoard(),
11
+ useAgGrid(), createGridBuffer(), createColumnBuffer(), createUiSlot(), createToolbar()
12
+
13
+ Old names are recorded only in `WENAY_REACT2_RENAMES.md`; do not export new compatibility aliases casually. Existing public APIs should usually remain supported during internal refactors; removals require an explicit migration cut with a separate task, migration notes, changelog, and usage signal. Default style contracts are stricter: do not remove old default classes/styles until the replacement class/token path is shipped and documented.
14
+ Do not add new examples with Get*, FuncJSX, *2/*3, or business-specific helper names.
15
+ If a primitive needs app policy, build a small app wrapper above it.
16
+ If behavior is reusable, expose it first as a headless use*/create* API. The hook/controller may return methods, getters, props/bind helpers, or a small API object; visual components and QA cards should consume that surface instead of becoming the only implementation.
17
+ ```
18
+
19
+ Canonical method vocabulary:
20
+ ```
21
+ open / close / set / replace // modal-like state
22
+ update / remove / clean / sync // data-buffer state
23
+ fit / flush // ag-grid visual/transaction lifecycle
24
+ props / bind // spreadable element props
25
+ get / set / reset / cancel // local hook/controller state
26
+ on -> off // subscriptions
27
+ ```
28
+
29
+ ## Root Namespaces
30
+ ```
31
+ import { kit } from "wenay-react2"
32
+
33
+ kit.hooks
34
+ kit.dnd
35
+ kit.utils
36
+ kit.grid
37
+ kit.modal
38
+ kit.menu
39
+ kit.logs
40
+ kit.updateBy
41
+ ```
42
+
43
+ The root export is still flat. `kit` is useful when a large file needs grouped names.
44
+
45
+ ## Modal Low Level
46
+ ```
47
+ useModal() // callable controller with show/open/close/set
48
+ createModalElementStore() // imperative JSX store; prefer ModalProvider/useModal
49
+ createModalRenderStore() // function JSX store; prefer ModalProvider/useModal
50
+ inputModal({modal, func, name?, txt?})
51
+ confirmModal({modal, func, password?})
52
+ ```
53
+
54
+ `inputModal` and `confirmModal` accept either a setter function or the `useModal()` controller.
55
+
56
+ Internal Overlay (A9, 2026-07-10): `components/Overlay.tsx` is the ONE portal+scrim+outside-click+
57
+ Escape composition; `ModalProvider` and `SettingsDialog` are adapters over it (behavior 1:1,
58
+ SettingsDialog keeps its own two-stage Escape by NOT passing `onEscape`). It is deliberately not
59
+ exported: apps use ModalProvider/useModal/SettingsDialog. The render-slot stores above, the
60
+ LeftModal drawer (gesture-driven, scrim-less), and `ModalWrapper`/Input helpers (backdrop-less,
61
+ hosted inside another modal slot) are NOT overlays and stay separate. This leaf is also where all
62
+ scrim/portal DOM lives - the seam for a future react-native view layer.
63
+
64
+ Left-side modal/menu helpers:
65
+ ```
66
+ LeftModal({arr, zIndex})
67
+ ApiLeftMenu
68
+ getApiLeftMenu()
69
+ TestLeft333()
70
+ ```
71
+
72
+ These are app-shell style utilities. Prefer local app wrappers for new layouts.
73
+
74
+ ## Settings Dialog / UI Slot / Callback Hub Details
75
+ Settings dialog (centered panel ~720x480, max 92vw/82vh; searchable settings tree left, content right):
76
+ ```
77
+ type SettingsSection = {key: string, name: string, render: () => ReactNode, children?, parentKey?, searchText?, keywords?}
78
+
79
+ <SettingsDialog
80
+ trigger={...} // wrapper span is clickable
81
+ sections? // static sections, listed first; children form tree nodes
82
+ defaultSection? // falls back to the first section when missing/unmounted
83
+ sectionClassName? // apps pass their own .chip; default .wenayDlgSection
84
+ sectionActiveClassName? // apps pass their own .chipActive; default .wenayDlgSectionActive
85
+ />
86
+
87
+ useSettingsDialogController({sections?, defaultSection?, sectionClassName?, sectionActiveClassName?})
88
+ registerSettingsSection(s) -> unregister
89
+ getSettingsSections() -> readonly SettingsSection[]
90
+ ```
91
+ The registry is a module singleton on updateBy/renderBy (no React context). Registering the same
92
+ `key` replaces the previous section; unregister removes by identity, so a stale unregister after a
93
+ replacement is a no-op. Tree shape comes from nested `children` or flat sections with `parentKey`.
94
+ Search matches section labels, `keywords`, `searchText`, and best-effort text extracted from rendered
95
+ React children; matching descendants stay visible and their ancestors auto-expand. The search input uses
96
+ `createSearchHistory({key:"SettingsDialog.searchHistory"})`: Enter commits the current query, the history
97
+ button recalls stored queries, leaving the search box closes the list, and changes are published through memoryProps -> memoryCache. `useSettingsDialogController` exposes the same open/search/tree/history/nav-resize actions for custom settings chrome. Tree controls
98
+ are one compact dotted cycle button in the search row (expanded -> current branch -> collapsed), hidden when
99
+ the tree has too little hierarchy to need it. Closes on the x, a scrim click, and Escape.
100
+ Styling: `--dlg-scrim / bg / border / radius / shadow / nav-bg / nav-width` tokens (tokens.css,
101
+ mirror `tokens.dlg`), classes `.wenayDlgScrim / .wenayDlg / .wenayDlgNav / .wenayDlgSearch /
102
+ .wenayDlgTree / .wenayDlgContent / .wenayDlgSearchHistory` in style.css; dark defaults, apps re-skin
103
+ via `:root[data-theme]` like `--wnd-*`.
104
+ UI slot:
105
+ ```
106
+ createUiSlot({key, places, def}) -> {Slot, PlacementSetting, getPlace, setPlace}
107
+ <slot.PlacementSetting className? activeClassName? /> // defaults .wenaySegBtn / .wenaySegBtnActive
108
+ ```
109
+ State lives in `memoryGetOrCreate(key)` -> persisted with the rest of memoryProps; `setPlace`
110
+ announces the in-place mutation via `memoryMarkDirty(key)` and the APP decides when to save
111
+ via `memoryCache.onDirty` (the library never writes storage itself, same as window state).
112
+ A stored place that no longer exists in `places` falls back to `def`. `getPlace()` is not reactive;
113
+ Slot/PlacementSetting subscribe internally via updateBy.
114
+
115
+ Toolbar (createToolbar, `components/Toolbar/Toolbar.tsx`):
116
+ ```
117
+ createToolbar({key, items, def?, settingsItem?, resetItem?, source?, sourceMode?}) -> {Bar, Settings, api: {useConfig, useItems, getConfig, setConfig, setOrder, show, setDensity, showSettings, showReset, reset, onChange}}
118
+ <tb.Bar className? settings? reset? popAlign? /> // default classes .wenayTb / .wenayTbItem; popAlign
119
+ // 'right' (default, top-right bars) | 'left'
120
+ <tb.Settings className? activeClassName? /> // density segments default .wenaySegBtn(Active)
121
+ registerToolbarDensity({key, name, renderItem?}) / getToolbarDensities()
122
+ toolbarItemIcon(item) -> ReactNode // item.icon, or first letters of short/title
123
+ ```
124
+ Three decoupled layers: serializable config `{order, visible, density}` (single source of truth,
125
+ persisted exactly like createUiSlot: memoryGetOrCreate(key), edits mutate in place + renderBy +
126
+ memoryMarkDirty, the APP saves via memoryCache.onDirty), Bar (visible!==false, in order, at density) and
127
+ a PURE Settings editor over config - the same element works in the Bar's gear popover and in a
128
+ registered settings section, no prop changes. Semantics that are easy to get wrong:
129
+ - Merge of stale persist: unknown keys in stored order/visible are ignored (view-level; raw
130
+ storage is untouched until the next setConfig), items missing from the config are APPENDED
131
+ default-visible - an app update never wipes the user's order/visibility.
132
+ - `fixed` items: always visible, pinned at normalize() to their index in opts.items (checkbox
133
+ disabled, not draggable) - and the drag PREVIEW already respects the pinning, so they never
134
+ move visually and a drop never lands elsewhere than shown.
135
+ - Density is an extensible module registry (like registerSettingsSection): entries may carry
136
+ `renderItem(item)`; item.render(density) always wins; entries without renderItem fall back to
137
+ icon + (short ?? title). A persisted density that is no longer registered falls back to
138
+ def/first. Bar/Settings subscribe to the registry (updateBy), so registering a level updates
139
+ live.
140
+ - Reorder rides the library's own `useReorder` (see Drag / Resize Low Level; this editor is its
141
+ first consumer): the WHOLE row drags, mouse+touch, checkbox excluded, and `move` is the SAME
142
+ simulated commit as normalize() (splice + fixed pinning), so preview and drop agree by
143
+ construction. Neighbours glide via transitioned transforms (`.wenayTbRowShift`), ONE setConfig
144
+ on drop. Plus arrow keys on the focused handle (the drag hook preventDefaults mousedown, so the
145
+ row handler focuses the handle itself). No dnd dependency; FloatingWindow/react-rnd deliberately not
146
+ used (free-floating windows, wrong tool). `touch-action: none` + `user-select: none` on
147
+ draggable rows (`.wenayTbRowGrab`).
148
+ - `api.onChange` is a wenay-common2 `listen` stream; emits the NORMALIZED config after every
149
+ config edit. useConfig subscribes via updateBy; getConfig is a non-reactive snapshot.
150
+ `setOrder(order)` is the focused external-control path for grid/menu reorder sync: it preserves
151
+ current membership, density, and pseudo-control visibility while changing only item order. `show(key,on)`,
152
+ `setDensity(key)`, `showSettings(on)`, and `showReset(on)` are the matching narrow writes; `setConfig` remains the low-level full write.
153
+ - The gear and reset buttons are pseudo-items: reserved visible keys `__settings` and `__reset`,
154
+ NO order slots (they always sit at the bar edge), toggled from separated rows at the bottom of
155
+ the editor (`.wenayTbRowMeta`, outside the drag-slot container on purpose). Hiding the gear in
156
+ its own popover closes the popover - the global settings section is the way back. The reset
157
+ button calls `api.reset()` and restores defaults for order, membership, density and pseudo-control
158
+ visibility. Settings is default-visible; reset is default-hidden unless `resetItem.defaultVisible=true` or `api.showReset(true)` enables it. `resetItem: false` removes that feature. Faces via `settingsItem` / `resetItem`.
159
+ - **The Settings CONTAINER is the client's choice (not the core's).** `tb.Settings` is
160
+ presentation-agnostic - render it anywhere. `<Bar settings>`'s gear opens it in a small inline
161
+ popover anchored to the gear; since the bar reflows when you toggle membership, that popover
162
+ shifts a little (known minor twitch). The core deliberately ships ONLY that inline popover -
163
+ anything richer is a client layer. For a stable, movable editor render `tb.Settings` in your own
164
+ container: a registered settings section (`registerSettingsSection`), or a draggable window -
165
+ `FloatingWindow` (drag by header, close X, viewport-clamped, position persisted via `keyForSave`)
166
+ wrapped in `OutsideClickArea` for close-on-outside-click. Recommended pattern for clients who
167
+ want the modal-with-title-bar-and-close look; QA card 30 demonstrates it (gear -> Settings in a
168
+ FloatingWindow, in the card's own positioned layer so it scrolls with the card). This is separate
169
+ from the row REORDER above, which stays on `useReorder` (a floating window would be the wrong tool
170
+ there).
171
+ - `api.useItems()` is the headless bar: ordered, visibility-filtered `[{item, density, content}]`
172
+ for fully custom markup. Refs-out was rejected deliberately: order lives in the config, so the
173
+ consumer re-renders from this list - the library never re-parents foreign DOM nodes.
174
+ - `icon` is OPTIONAL: `toolbarItemIcon(item)` returns the icon, else the first 3 letters of
175
+ short/title as a text pseudo-icon (icon density only - the label density shows the caption, not
176
+ the letters). Exported so menus (ColumnsMenu compact) share the exact rule.
177
+ - `source?: UiListSource` inverts ownership. Default `sourceMode:"orderVisible"` makes the toolbar a
178
+ VIEW over source order+visibility: setConfig writes item order/visibility THERE (its own change
179
+ flow re-emits `api.onChange`; the toolbar emits itself only if the source has no `onChange`), while
180
+ density and the gear/reset flags stay in the toolbar's own `st` under `key`. `sourceMode:"order"`
181
+ shares only the relative order of keys present in the source; item membership, density,
182
+ pseudo-controls, and extra non-source item positions stay local. On source reorders, source keys
183
+ refill their slots in the local toolbar order, so an extra item like QA card 30's `blockMode` is
184
+ not pushed into `columnState` and does not need a bridge hook. `ext` is per-instance constant, so
185
+ hook-call order in useConfig/Bar/Settings never changes. `columnState.api.listSource` is the
186
+ reference implementer (QA card 31 for order+visibility, QA card 30 for order-only). Without a source
187
+ - v1 non-goals: no overflow/"more" menu (hook point: Bar, after the visible-items map), no
188
+ grouping, no cross-bar drag.
189
+ Styling: `--tb-*` tokens (tokens.css, mirror `tokens.tb`), classes `.wenayTb*` in style.css;
190
+ dark defaults, apps re-skin via `:root[data-theme]` like `--wnd-*` / `--dlg-*`.
191
+
192
+ Callback hub (for single-slot callback APIs `onX(cb | null)` whose subscribers silently
193
+ overwrite each other):
194
+ ```
195
+ createCallbackHub<Args>(bind) -> {on, count}
196
+ ```
197
+ `bind(emit)` runs lazily, ONCE, on the first `on()` - not at creation time, because the slot may
198
+ still be taken by app initialization. The first callback is registered before bind runs, so it
199
+ also catches synchronous emits. `on(cb) -> off`; off removes only that subscriber.
200
+
201
+ ## columnState (createColumnState, `grid/columnState/`)
202
+ A persisted column layer over a keyed column set - order/visibility/width/sort/filter in a
203
+ standalone config store, plus an OPTIONAL two-way ag-grid adapter. Mobile card views consume the
204
+ SAME config with no ag-grid at all. agGrid4 is never modified: this is exactly the app-level
205
+ column wrapper WRAPPER.md postulates, packaged as a primitive; ag-grid enters only as a type
206
+ import + the GridApi handed to grid.attach().
207
+ ```
208
+ createColumnState({key, columns: ColumnMeta[], def?, saveMs?=300})
209
+ -> {columns, api, grid: {attach(api), detach()}}
210
+ ColumnMeta = {key, title, short?, icon?, group?, fixed?, defaultVisible?, cardRole?: 'title'|'accent'}
211
+ ColumnsConfig= {v, order, visible, width, sort: {key, dir} | null, filter, groups}
212
+ api: {getConfig, setConfig, useConfig, onChange, reset, show(k,on), move(order), setSort(s|null),
213
+ toggleSort(k), visibleKeys(), getPresent, usePresent, isPresent(k), setPresent(keys|null), getPresentGate, setPresentGate(keys|null), listSource}
214
+ ```
215
+ Persist + migration (same DNA as createToolbar/createUiSlot): config lives in `memoryGetOrCreate(key)`,
216
+ edits mutate it in place + renderBy + memoryMarkDirty, the APP saves via `memoryCache.onDirty`.
217
+ `normalize()` is the soft migration - unknown keys dropped, missing columns appended
218
+ default-visible, `fixed` pinned to its descriptor index; `v` covers incompatible shape changes.
219
+ The persisted object's IDENTITY is the subscription key, so `memoryCache.load()` must run BEFORE the grid
220
+ mounts (a grid attached pre-load would apply defaults; QA card 28 gates the grid on `loaded`).
221
+
222
+ Semantics that are easy to get wrong:
223
+ - STICKY sort: `sort` is independent of visibility and of any UI selection, may point at a hidden
224
+ OR grid-absent column, and changes only by an explicit toggle/header click. `readFromGrid` keeps
225
+ it when the live grid lacks that column (the grid cannot express a sort by a column it does not
226
+ have), so hiding or removing the sorted column never silently resets it.
227
+ - Two-way loop protection: store->grid runs under an `applying` flag; grid->store ignores events
228
+ while `applying`, with `source=='api'`, or `finished===false` (resize/move fire per animation
229
+ frame - only the final shape persists), debounced `saveMs`, and a JSON compare before commit. A
230
+ `fixed` column dragged off its slot in the grid: readFromGrid commits, normalize() pins it back,
231
+ and if the order changed the adapter re-applies to the grid (snap-back) so the two never disagree.
232
+ - Presence (`usePresent/isPresent/setPresent/setPresentGate`): runtime-only, NEVER persisted. It is
233
+ the intersection of actual live-grid columns and an optional app-level gate. The adapter maintains
234
+ actual presence on attach and on `gridColumnsChanged`; a column removed from the grid (dynamic
235
+ columnDefs) keeps its config entry and its menu button (rendered disabled), and when the set changes
236
+ the adapter RE-IMPOSES the config so a returning column regains its stored order/width/visibility
237
+ (setting columnDefs resets grid order). `setPresentGate(keys|null)` is for stable-schema app modes:
238
+ it marks gated-out buttons disabled and hides those grid columns through applyColumnState without
239
+ mutating persisted `visible`. No loop: applyColumnState never adds/removes columns. Null actual presence
240
+ and null gate together mean all present; if a gate is set, grid-less consumers still see that gate.
241
+ - `listSource` = the `{order, visible}` slice exposed as a `UiListSource` (Toolbar's
242
+ external-config contract): plug it into `createToolbar({source})` and the toolbar, the icon menu
243
+ and the grid all mirror one config. Its setConfig MERGES visible and re-appends via normalize, so
244
+ an editor over a SUBSET of columns never drops the rest.
245
+ - `filter`/`width` are written by the grid adapter only; `groups` (group key -> enabled
246
+ sub-columns) is modelled now; core group UI is still app-owned. QA card 30 demonstrates an app-level
247
+ mode button over stable grouped `columnDefs`; `presentGate` hides unavailable leaf columns and keeps their buttons disabled.
248
+ `visibleKeys()` additionally gates a grouped column by its group's enabled set.
249
+ - Attach from the consumer's `onGridReady` (AgGridTable forwards it over its own wiring) with
250
+ `autoSizeColumns={false}` (auto-fit at mount would rewrite stored widths); detach from
251
+ `onGridPreDestroyed` - the config outlives the grid (columnBuffer pattern). HMR caveat on the
252
+ stand: hot-reload recreates a module-level controller while the live grid stays attached to the
253
+ OLD instance, so store->grid needs an F5; in a real app the module runs once.
254
+
255
+ High-level kit (`createColumnGrid`, `grid/columnState/columnGrid.tsx`):
256
+ ```
257
+ createColumnGrid({key, columnDefs?, columns?, data?, getId?, def?, saveMs?, toolbar?, autoSizeOnColumnCountChange?})
258
+ -> {state, toolbar, api, grid, tableProps, Table, Menu, Dots, Cards, Toolbar, Settings, View}
259
+ useColumnGrid(opts) -> same controller, captured once for component-local setups
260
+ ```
261
+ - This is the default convenience layer for ordinary app grids that all need the same column menu.
262
+ It infers `ColumnMeta` from leaf `ColDef`s (`colId` -> `field`, title from `headerName` -> key)
263
+ and merges optional `columns` overrides for `fixed`, `short`, `icon`, `defaultVisible`, `cardRole`,
264
+ and group semantics. Unknown explicit overrides are appended, so grid-less/card-only fields still work.
265
+ - `Table` and `tableProps()` wrap `AgGridTable` and wire `state.grid.attach/detach` automatically.
266
+ Their default `autoSizeColumns=false` is deliberate: the wrapper is for persisted column layout, so
267
+ auto-fit must be opt-in. `autoSizeOnColumnCountChange` is a narrower opt-in: it calls
268
+ `sizeColumnsToFit()` only when `visibleKeys().length` changes after a columnState edit.
269
+ - `View` is a small representation switch (`mode:'table'|'cards'`) with controls
270
+ (`'menu'|'dots'|'toolbar'|false|'auto'`). `auto` resolves to the built-in dots overlay for both
271
+ table and card mode; the wrapper sets dot max to the full column count unless overridden. `Dots` is
272
+ not card-specific; on table mode it edits the same config and the attached grid applies visibility,
273
+ order, and sort through columnState. `data`/`getId` can live on the controller defaults or be
274
+ passed per `View` render.
275
+ - Use raw `createColumnState` plus `createToolbar` when the app needs a custom skin like QA card 30,
276
+ a runtime `presentGate`, special group mode buttons, or nonstandard persistence timing.
277
+
278
+ Icon menu (ColumnsMenu / MenuStrip, `grid/columnState/ColumnsMenu.tsx`):
279
+ ```
280
+ <MenuStrip items={MenuStripItem[]} onItem? onMove? move? tail? holdMs?=150 compact? />
281
+ MenuStripItem = {key, title, short?, icon?, state: 'on'|'off'|'disabled', marks?, fixed?}
282
+ <ColumnsMenu state onItem? marks? tail? onTail? reorder?=true holdMs? compact? />
283
+ ```
284
+ Two DECOUPLED layers, because "what a click means" is deliberately not the strip's business:
285
+ - `MenuStrip` is pure presentation - ordered buttons in three states plus a `marks` adornment
286
+ ("on with extras": sub-columns, naming strategies...); it reports clicks (onItem) and
287
+ drag-reorders (onMove) but interprets neither. Reusable for ANY multi-state button strip.
288
+ `disabled` buttons report no clicks; `fixed` ones do not drag; `tail` = buttons after a divider,
289
+ OUTSIDE the reorder list (mode cyclers/actions). Reorder rides `useReorder` (second consumer
290
+ after the Toolbar editor) with the same fixed-pinning `move`, so preview == drop.
291
+ - `ColumnsMenu` binds it to a columnState: buttons follow config.order (the grid mirror is free -
292
+ both read the same config), state = disabled (absent from the live grid) / on / off, default
293
+ click = toggle visibility (overridable via `onItem` for multi-state columns), drag = api.move.
294
+ It is the compact/presentation surface. For settings-integrated bars that need density,
295
+ pseudo-controls, reset, and global Settings reuse, use `createToolbar({source: cs.api.listSource})`
296
+ as the canonical richer surface and optionally render a compact `ColumnsMenu` beside it.
297
+ - Click-vs-drag guard: a drag that ends on its origin button still fires a browser click; a click
298
+ that travelled >4px from mousedown is dropped, so a snapped-back drag never also toggles.
299
+ - `compact` = icon-only buttons: the icon, or the first letters of short/title as a text
300
+ pseudo-icon (full title in the tooltip), the same rule as `toolbarItemIcon`.
301
+
302
+ Mobile (no ag-grid, no storage - the config alone):
303
+ ```
304
+ <ColumnDots state max?=8 className? style? /> // grid/columnState/ColumnDots.tsx
305
+ <CardList<Row> state data getId? renderValue? /> // grid/columnState/CardList.tsx
306
+ ```
307
+ - `ColumnDots` is a discrete multi-thumb slider on pointer events (react-range cannot change thumb
308
+ count): a track of marks (one per column), dots on the shown columns. Gestures use a
309
+ dominant-axis test so a horizontal drag never removes and a vertical flick never reorders: drag a
310
+ dot along the track = the column takes another (empty) mark; swipe a dot UP = hide (fixed and the
311
+ last remaining dot stay); tap an empty mark = show (up to `max`); tap a dot without moving =
312
+ select the field; the sort button cycles asc->desc->off on the SELECTED field (sticky - selecting
313
+ another dot does not touch it). 44px touch targets.
314
+ - `CardList` renders rows as blocks: `visibleKeys()` become the card fields (created/removed live
315
+ as dots move), `cardRole:'title'` = the header (else the first visible key), `cardRole:'accent'`
316
+ = a badge, the rest are label+value. It sorts by the sticky `config.sort` itself
317
+ (numeric/locale comparator) even when the sorted column is hidden.
318
+ Styling uses shared classes `.wenayColDots*` / `.wenayCardList*`; runtime geometry remains inline because dot positions and drag transforms are state-derived. QA cards 28 (grid layer + F5 restore), 29 (mobile dots + cards), 30 (toolbar icon menu + grouped sub-column mode button), 31 (Toolbar over columnState.listSource + pseudo-icons), 32 (createColumnGrid wrapper + dots driving table/cards).
319
+
320
+ ## Outside / Buttons Compatibility
321
+ ```
322
+ useOutsideRef(options) -> ref // use useOutside(options).ref / .props
323
+ OutsideClickArea // alias of OutsideClickArea
324
+
325
+ StyleOtherRow
326
+ StyleOtherColumn
327
+ ```
328
+
329
+ `Button`, `OutsideButton`, `HoverButton`, and `AbsoluteButton` are still direct components rather than hook controllers.
330
+
331
+ `Button` persists its open/closed status under `keyForSave` (module-lifetime; same naming as
332
+ FloatingWindow/Resizable/RightMenu); the old `keySave` prop is a deprecated alias (`keyForSave`
333
+ wins when both are set).
334
+
335
+ ## Drag / Resize Low Level
336
+ ```
337
+ FloatingWindowBase(props) // lower-level react-rnd wrapper
338
+ FloatingWindow(props) // canonical floating window component
339
+ useFloatingWindowController(options) // headless geometry/stack/drag/resize controller for custom chrome
340
+ floatingWindowMap // persisted RND map (ObservableMap)
341
+ FloatingWindowUpdate
342
+ FloatingWindowProps / FloatingWindowController / FloatingWindowSavedGeometry
343
+
344
+ DragBox(props) // delta-drag component; thin adapter over useDraggableApi (A7)
345
+ DragArea(props) // @deprecated: unique semantics kept as-is; prefer useDraggableApi/DragBox
346
+ FResizableReact(props)
347
+ mapResiReact // persisted resize map (ObservableMap)
348
+ OutlineDragDemo()
349
+ ```
350
+
351
+ For new pointer logic, prefer:
352
+ ```
353
+ const drag = useDraggableApi(...)
354
+ <div {...drag.bind} />
355
+ ```
356
+
357
+ `useDraggable(...)` is kept as the simple hook wrapper; `useDraggableApi(...)` is the controller-style shape.
358
+
359
+ Drag primitives after the A7 pass (2026-07-10): `DragBox` is a thin adapter over
360
+ `useDraggableApi({holdMs: 0, trackState: false, onMove})` - same observable contract as its old
361
+ bespoke loop (immediate start, per-tick imperative `onX`/`onY` with the delta from the press
362
+ point, no re-render per move tick, `onStop` only after a real gesture, `last` ref shares the live
363
+ position object; pinned by `__test/dragBox.test.tsx`, QA card 35). `DragArea` stays untouched and
364
+ `@deprecated`: no consumers, and its semantics are unique (document.body listeners,
365
+ `stopPropagation` per move tick, ABSOLUTE coords, no preventDefault) - re-basing it would change
366
+ observable behavior; removal only in a breaking version. `useFloatingWindowController`'s header
367
+ loop and `StickerMenu` deliberately keep their own loops: viewport clamp + `e.buttons===1` release
368
+ recovery + persistence/z-stack, and mount-lifetime listeners + click-vs-drag suppression
369
+ respectively, are load-bearing behavior `useDraggableApi` does not model.
370
+
371
+ Reorder-by-drag (useReorder, `hooks/useReorder.tsx` - extracted from the Toolbar editor, which is
372
+ its first consumer):
373
+ ```
374
+ useReorder({order, commit, move?, canDrag?, preview?: 'slots'|'measure', holdMs?})
375
+ -> {listRef, item(key) -> {props, style?, dragging, active}, dragKey, preview}
376
+ ```
377
+ A deliberately SMALL reorder for keyed blocks laid out by CSS - the hook never knows the layout
378
+ (vertical list, horizontal bar, wrapped grid all work). Semantics:
379
+ - DOM order never changes mid-drag: the dragged block follows the pointer via transform, the rest
380
+ glide to their preview position (consumer adds the transition on `active && !dragging`), ONE
381
+ `commit(next)` on drop, skipped when nothing moved (a plain click is a no-op).
382
+ - Targeting = nearest slot center measured at drag START - never against the live layout
383
+ (re-measuring moving blocks oscillates at boundaries; designed out). Pointer deltas (viewport
384
+ px) are normalized by the container's scale factor (rect.width / offsetWidth), so
385
+ `transform: scale` / zoomed ancestors do not skew targeting.
386
+ - `move` is the simulated commit: the preview shows exactly `move(order, key, to)` - consumers
387
+ with pinning rules (Toolbar's fixed items) pass their own so preview == drop by construction.
388
+ - preview 'slots' (default) transforms between start slot centers - exact when blocks are
389
+ equal-sized. 'measure' is FLIP: the preview order is applied via CSS `order`, layout offsets
390
+ (offsetLeft/Top) are read and reverted in one synchronous pass (never paints), so wrapping with
391
+ ANY block sizes previews exactly; requires a flex/grid container; measured once per target
392
+ change. Offsets, NEVER getBoundingClientRect: rects include mid-flight TRANSITION values
393
+ (style.transform='none' does not stop a running transition within the same synchronous pass),
394
+ so rect-based re-measures accumulated the previous preview's shifts on every target change and
395
+ the blocks flew apart on target oscillation.
396
+ - Events from `input/button/select/textarea/a` never start a drag; mouse is left-button only;
397
+ touch works (`touch-action: none` on blocks is the consumer's CSS).
398
+ - Non-goals (use a ready-made dnd library instead): nesting, spans/collision packing,
399
+ autoscroll. QA card 26 is the live example. Cross-COLUMN moves live in useReorderBoard below.
400
+
401
+ Board (useReorderBoard, `hooks/useReorderBoard.tsx` - the columns extension of useReorder):
402
+ ```
403
+ useReorderBoard({columns: [{key, items}], commit(next), canDrag?, holdMs?,
404
+ onDragStart?, onDragMove?, onOverChange?, onDragEnd?})
405
+ -> {columnRef(col) -> RefCallback, item(key), dragKey, over: {col, index} | null}
406
+ ```
407
+ - Columns are plain consumer divs registered via `columnRef(key)` (live callback-ref registry -
408
+ ADDING a column is just consumer state + one more div, spliced at ANY position of the columns
409
+ array incl. the middle); a column's children must be exactly its items, 1:1, in order. The
410
+ column set/geometry is frozen for the duration of one drag.
411
+ - Column gravity is pure consumer CSS (`justify-content: flex-start` packs up, `flex-end` packs
412
+ down) - the hook never knows it, it measures the real layout. Do NOT use `flex-direction:
413
+ column-reverse` (it inverts DOM-vs-visual order; the 1:1 mapping breaks).
414
+ - Targeting: nearest column by clamped rect distance, insertion index = how many of that column's
415
+ START item centers sit above the dragged center (start geometry only - anti-oscillation, same
416
+ rule as useReorder). Preview = simulated commit (movedColumns), so preview == drop.
417
+ - Cross-column FLIP, offset-based like useReorder: the dragged block leaves flow via
418
+ display:none, survivors get preview CSS `order`, and the landing slot becomes a real
419
+ margin gap (draggedHeight + row-gap) on the neighbour - the column's own gravity then decides
420
+ who moves aside (a flex-end column slides the blocks ABOVE the slot up). Apply-read-revert in
421
+ one synchronous pass, cached per target slot.
422
+ - Callbacks: onDragStart (grab), onDragMove (every move, pointer delta in local px),
423
+ onOverChange (only when the target slot changes; compare prev.col != over.col for column
424
+ crossings), onDragEnd (final slot + committed flag; a plain click is committed=false).
425
+ `over`/`dragKey` are also returned reactively for render-time styling (column highlight).
426
+ - QA card 27 is the live example (5 columns, mixed gravity, empty column, add-column).
427
+
428
+ ## Grid Row Utilities
429
+ ```
430
+ applyGridRows(params)
431
+ ```
432
+
433
+ Use `agGrid4` for new tables:
434
+ ```
435
+ createGridBuffer()
436
+ useAgGrid()
437
+ AgGridTable
438
+ createColumnBuffer()
439
+ numericComparator()
440
+ colDefCentered
441
+ colDefWrap
442
+ ```
443
+
444
+ Important agGrid4 contracts:
445
+ ```
446
+ mirror mode: buffer owns row set; sync can add/update/remove grid rows.
447
+ overlay mode: rowData owns row set; sync updates only existing grid rows.
448
+ clean(): clears buffer; mirror also removes grid rows, overlay does not remove declarative rows.
449
+ flush(): React/controller method that calls ag-grid flushAsyncTransactions().
450
+ ```
451
+
452
+ Plain declarative `rowData` should preserve AgGridReact defaults unless the caller provides `getRowId`.
453
+ Buffered paths need a stable `getId`.
454
+
455
+ Dynamic columns:
456
+ ```
457
+ createColumnBuffer().api.setNames(names) // exact set, dedupe preserving order
458
+ createColumnBuffer().api.apply() // replay last attached apply callback
459
+ createColumnBuffer().control.attach(api, {apply})
460
+ createColumnBuffer().control.detach() // keep names
461
+ ```
462
+
463
+ The primitive must not contain product group names, base `columnDefs`, visibility rules, or app naming policy.
464
+
465
+ ## Params / Generated Editors
466
+ ```
467
+ ParamsEditor({params, onChange, onExpand?, expandStatus?, expandStatusLvl?})
468
+ useParamsEditorController({params, onChange, onExpand?})
469
+ ParamsEdit({params, onSave}) // canonical compact editor
470
+ ParamsArrayEdit({params, onSave}) // array params editor
471
+ ParamRow({param, onClick, type?})
472
+ ParamLabelContent(name)
473
+ ParamToggleLabel(type, name)
474
+ ```
475
+
476
+ `ParamsEditor` receives wenay-common2 `Params.IParamsExpandableReadonly` shape. `useParamsEditorController` owns the mutable draft clone, immediate/delayed notify, expand callback, and timer cleanup for custom renderers; `ParamsEditor` remains the compatibility visual wrapper.
477
+
478
+ ## Menu Low Level
479
+ ```
480
+ Menu({data, coordinate?, zIndex?, className?, menu?, menuElement?})
481
+ MenuElement
482
+ MenuProgress
483
+ MenuItemStrict / MenuItem
484
+
485
+ contextMenu.openAt(eventOrPoint, items, {source?, layerId?}) -> boolean
486
+ contextMenu.openAtPoint({x, y}, items, {source?, layerId?}) -> boolean
487
+ contextMenu.close()
488
+ contextMenu.getState() -> {open, items, point, source?, layerId?, seq}
489
+ contextMenu.stats.getSnapshot() -> {openAt, openAtPoint, legacyLayer, close, replace, empty, sources, layers, actionTotals, actions}
490
+ contextMenu.stats.reset()
491
+ contextMenu.stats.onChange(cb) -> off
492
+ <contextMenu.Layer zIndex? statusOn? other? className?>...</contextMenu.Layer>
493
+ contextMenu.map // legacy queue consumed by Layer; prefer openAt
494
+ createContextMenu({name?}) // custom isolated instance with its own state and stats
495
+ createRightClickMenu() // lower-level legacy right-click factory
496
+
497
+ DropdownMenu({elements, trigger?, classNames?, styles?, style?, position?, verticalPosition?, keyForSave?})
498
+ useRightMenuController({elements, style?, styles?, position?, verticalPosition?, keyForSave?})
499
+ createRightMenuController()
500
+ mapRightMenu // persisted floating-menu state (ObservableMap, RightMenuStore)
501
+ MenuRightPosition / MenuRightVerticalPosition / MenuRightSavedState / MenuRightRenderProps
502
+ MenuRightTrigger / MenuRightClassNames / MenuRightStyles / RightMenuController
503
+ StickerMenu // components/Menu re-export
504
+ ```
505
+
506
+ Prefer `contextMenu.openAt(e, items, {source?})` for new right-click integrations. `contextMenu.map` remains for older callers that queue items before Layer handles the right-click, and stays supported through `1.x`; it should not be the primary API in new code. `contextMenu.stats` is local in-memory diagnostics, not hidden analytics: it counts direct `openAt`, `openAtPoint`, legacy Layer queued opens, empty opens, close/replace, source/layer usage, aggregate action outcomes, and keyed action outcomes. It deliberately does not persist, send network requests, or store arbitrary item labels. Per-action stats require explicit `MenuItemStrict.actionKey`; unkeyed actions update `actionTotals` only.
507
+
508
+ `Menu` does not mutate `item.status`. Open/hover state is an internal active index; `status` remains a seed/compatibility value, and custom item renderers receive a view item whose `status` mirrors the current open state.
509
+
510
+ `DropdownMenu` is a floating action menu, not the main context-menu primitive. Its trigger glyph/content and visual classes/styles are caller-owned through `trigger`, `classNames`, and `styles`; the default still renders the old hamburger glyph and CSS classes. `useRightMenuController` is the hook/controller layer behind it for custom views; do not duplicate hover/fixed/submenu timers in product code.
511
+
512
+
513
+ ## Observe / Listen React Hooks
514
+ Canonical React adapter names:
515
+ ```
516
+ useStoreNode(node, {mode?, fallback?, drain?, key?})
517
+ useStoreKeys(node, {fallback?, drain?, key?}) // object-shape changes: add/delete keys
518
+ useStoreChangedPaths(changedPathsListen, {initial?, key?})
519
+ useStoreEach(store, cb, {enabled?=true}) // store.each() wrapper: cb(key, value, ctx) per CHANGED top-level key; undefined = deleted; root replace expands per key; renders nothing itself
520
+ useStoreSelect(selection, {fallback?, drain?, key?})
521
+ useStoreMirror(remote, initial, {mask, current?=true, drain?, key?, auto?=true, partial?, onError?})
522
+ useListenEffect(listen, cb, {current?, key?})
523
+ useListenArgs(listen, {initial?, current?, key?})
524
+ useListenValue(listen, {initial?, current?, key?, map?})
525
+ ```
526
+
527
+ These hooks are intentionally in `wenay-react2`, not in `wenay-common2`.
528
+ Do not add React hooks for every helper exposed by `wenay-common2`; read the installed common2 package docs when needed, and document only the React-facing contract here. Hooks are useful only around subscriptions, lifecycle, or external resources.
529
+ Network sync stays explicit through `useStoreMirror`; reading a node must not secretly start RPC/fetch/WebSocket work. When `remote.changedPaths` exists and `partial !== false`, mirror uses common2 partial sync; otherwise it falls back to `remote.changed -> get(mask)`. `initial` seeds the mirror only. Structurally equal inline masks are stable, because StoreMask is expected to be a small plain object/tree.
530
+
531
+ QA stand coverage:
532
+ ```
533
+ /__qa/observe-store/get // HTTP snapshot/mask read
534
+ /__qa/observe-store/mutate // HTTP server mutation, including deep leaf and deep key add/delete
535
+ /__qa/observe-store/events // SSE changed stream
536
+ /__qa/observe-store/events-paths // SSE changedPaths stream
537
+ ```
538
+
539
+ ## Replay React Hooks (details)
540
+ `src/common/src/hooks/useReplay.ts`. Client side of the wenay-common2 Replay stack only.
541
+ ```
542
+ useReplaySubscribe(remote, cb, {since?, keepSeq?=true, enabled?=true, onSeq?, onError?, staleMs?, onStale?, policy?, hint?})
543
+ -> {ready, error, stale, seq(), lastTs(), restart(since?)}
544
+ useReplayRouteSubscribe(remote, cb, {since?, keepSeq?=true, enabled?=true, label?, onSeq?, onError?, onRoute?, policy?, hint?})
545
+ -> {ready, error, route, switching, seq(), label(), active(), switchRoute(nextRemote, opts?)}
546
+ useStoreReplaySync(store, remote, sameOpts) -> same controller // Observe.syncStoreReplay wrapper
547
+ useStoreReplayRouteSync(store, remote, routeOpts) -> route controller // Observe.syncStoreReplayRoute wrapper
548
+ useStoreReplayMirror(remote, initial, sameOpts) -> controller & {store} // creates the mirror store in a ref
549
+ useStoreReplayRouteMirror(remote, initial, routeOpts) -> route controller & {store}
550
+ useStoreReplayEach(remote, cb, sameOpts & {initial?, drain?}) -> controller & {store} // per-key fold: Observe.syncStoreReplayEach counterpart
551
+ useReplayFrame(remote, cb, {intervalMs?=300, since?, keepSeq?=true, enabled?=true, hint?, onSeq?, onError?})
552
+ -> {ready, error, seq(), pull(hint?), restart(since?)} // pull-at-own-pace over remote.frame()
553
+ useReplayHistory(history, apply, {head?, reset?, tickMs?=300, autoPlay?=true})
554
+ -> {live, seq, head, pause(), play(), seek({seq?|ts?})}
555
+ ```
556
+ Semantics that are easy to get wrong:
557
+ - `cb`/`apply` go through refs: new function identity does NOT resubscribe. Resubscribe identity is `[remote, enabled, epoch, staleMs, policy]` — changing `staleMs` resubscribes (it is subscribe-time config in common2; under keepSeq the reconnect is a journal tail, so it is cheap); `policy` picks the wire surface (line vs frameLine), so it resubscribes too. `onStale` goes through a ref like `cb`.
558
+ - Route hand-off hooks (`useReplayRouteSubscribe`, `useStoreReplayRouteSync`, `useStoreReplayRouteMirror`) wrap common2 `Replay.replayRouteSubscribe` / `Observe.syncStoreReplayRoute`: `switchRoute(nextRemote, {label?, since?, reset?, policy?, hint?})` keeps the old route live, catches up the replacement from the last delivered `seq`, then closes the old route. Failed replacement leaves the old route active and surfaces `error` / `route.phase == "error"`. Changing the `remote` prop is still treated as a fresh subscription boundary; no-gap promotion/demotion is explicit through `switchRoute()`. common2 1.0.65 route handles expose `seq()`, `label()`, and `active()`, but not `isStale()` / `lastTs()`, so route hooks deliberately do not promise freshness yet.
559
+ - Lag policy / hint (frame model, common2 rev2): `policy: 'frame'` rides the server's `frameLine` when the remote has one — on lag the server drops for THIS client and recovers via the line's `frame` lambda (mini-frame); without a frameLine (old server, in-proc `exposeReplay`) common2 silently degrades to `'queue'`, so an in-proc QA of `'frame'` proves nothing — the wire test lives in common2 (`replay/rpc-auto.test.ts`). `hint` is opaque to the transport and goes through a ref: in `useReplaySubscribe` it is captured at subscribe time (used for the catch-up `frame()` call); in `useReplayFrame` it is read on EVERY pull, so a new identity is never missed and never resubscribes.
560
+ - `useReplayFrame` is the pull path: no live socket subscription at all; a timer calls `remote.frame(seq, hint)` and folds envelopes (seq-ascending, seen seq skipped — a keyframe recovery is just an envelope). Fresh start (no `since`) mirrors `replaySubscribe`'s catch-up for since<0: `keyframe()` is polled until the line has one (`frame(-1)` has no tail to return and THROWS on a still-empty line — a mount racing the producer's first event must not stick an error); a sacred line therefore NEEDS an explicit `since` (0 = full tail) — omitted, it waits forever. Overlapping pulls are never issued (a slow `frame()` skips ticks). Errors are LOUD and sticky: a reject (network, sacred line evicted past our seq, remote without `frame()`) stops the timer and sets `error` until `restart()` — deliberately no tail fallback here, that is `replaySubscribe`'s job. Freshness (`staleMs`) does not apply (the consumer owns the cadence).
561
+ - Freshness (`staleMs`): detection is 100% wenay-common2's watchdog (`ReplaySubscribeOpts.staleMs/onStale`, edge-triggered); the hook only mirrors the edges into `stale` state, so a fresh high-frequency line causes zero extra renders. `lastTs()` is a plain getter like `seq()` (producer ts of the last delivered event, 0 before the first delivery / while unsubscribed). Without `staleMs`: no watchdog, no state updates, `stale` stays `false`.
562
+ - Freshness on resubscribe/restart: the hook does NOT reset `stale` to `false` at effect start — it re-syncs from common2 after catch-up (mirrors the `onStale` edge fired during catch-up, plus `isStale()` once `ready` resolves), so a snapshot/tail carrying an old producer ts is stale from the first paint with no fresh-flicker. Caveat: common2's in-proc `keyframe()` stamps `ts` at request time, so a brand-new client on a stalled in-proc line reads as fresh at delivery and flips stale after `staleMs` via the arrival-gap watchdog; old-ts detection kicks in when the tail/keyframe actually carries producer timestamps (real relays, archives).
563
+ - `useReplayHistory` is archive playback — staleness intentionally does not apply there.
564
+ - `keepSeq`: within one mount, a resubscribe (StrictMode double effect, `enabled` toggle, `restart()`) reconnects with `{since: lastSeq}` -> journal tail. A NEW `remote` identity always resets seq (another line's seq is meaningless). A full unmount loses the refs: persist the position in the parent via `onSeq` and pass it back as `since` (QA card 23, client A).
565
+ - `seq()` and `useReplayHistory`'s interval-driven `seq/head` keep high-frequency lines from re-rendering per event. Frames/ticks should fold into a canvas/ref/store, not useState.
566
+ - `useReplayHistory.seek` kills the live subscription imperatively (guarded off, double-call safe), folds `history.at(where)` through `apply` (keyframe + tail; a keyframe fully redefines consumer state, so `reset` is rarely needed), and freezes. `play()` resubscribes `{since: pos}` -> archive tail -> live handover.
567
+ - `useStoreReplayEach` composes existing pieces instead of calling `Observe.syncStoreReplayEach` (which builds a FRESH store per call): the mirror lives in a ref (like `useStoreReplayMirror`), `store.each()` is subscribed in an effect declared BEFORE the sync effect (hook-call order guarantees the per-key subscriber exists when the keyframe applies — the expansion is never missed), then `useStoreReplaySync` drives the wire. Consequences: within one mount every resubscribe (StrictMode, `restart()`, `enabled` toggle, `staleMs`/`policy` change) reconnects by tail ON TOP of kept state and the consumer sees only the diff — no snapshot/`initial` dance (the library one-call needs `{since, initial: snapshot}` for that). After a FULL unmount pass `{since: prev.seq(), initial: prev.store.snapshot()}` like the library contract. `drain` is creation-time (it goes to `createStore`); changing it later does nothing. The fold target must live outside React state (ref/Map/grid api) — the hook renders nothing; `controller.store` is a normal store for `useStoreNode`/`useStoreKeys` extras.
568
+ - Server primitives (`conflateReplay` per connection, `archiveReplay`) intentionally have no hooks: they live where the RPC server is built.
569
+ - Route coordinator (common2 1.0.67 `Replay.createRouteCoordinator`): the coordinator owns promotion/fallback/shadow via policy hooks; consumers subscribe on a pair `link`, whose handle matches our route hooks (`ready, seq(), label(), active()`) minus `switchRoute` — the transport switch is invisible by the seq contract. Our route hooks (`useReplayRouteSubscribe` etc.) remain the manual-`switchRoute` wrapper over `replayRouteSubscribe`; they do NOT sit on top of a coordinator. React surface for coordinator links (subscribe hook + route-state/`promoteDirect` controls for ops UI) is a target-backlog item — do not bend the existing route hooks to accept links implicitly.
570
+ - WebRTC direct path (common2 1.0.68): `createWebRtcConnector({port, rtc, ...})` is the coordinator's direct `RouteConnector`; `rtc` is an injected factory and in the browser that injection (`() => new RTCPeerConnection(cfg)`) belongs to the app/React side — common2 stays lib.dom-free. Signaling rides the EXISTING rpc socket (`createSignalHub` port exposed by `createRpcServerAuto`), so no new connection management appears in React; channel death / revoke surfaces as loud `onError` on the line (never silence), which the existing hooks already map to `error`. Nothing here needs a hook by itself — it matters as the direct transport under a future adapter (target backlog).
571
+ - Peer SDK (common2 1.0.69 `Peer` / `wenay-common2/peer`): the one-call layer over rpc + store + replay + route coordinator — `createPeerClient({remote, account, initial, rtc?})` publishes the own store as a patch line and `peer(account)` returns a live mirrored store + route control; relay and direct share the OWNER's seq space (`createPatchRelayJournal`), so hand-offs are plain seq resumes, no keyframe reset. This is the intended React adoption surface (a `usePeer` adapter is the target-backlog item, superseding the raw coordinator-link hook idea): the mirrored store already plugs into `useStoreNode`/`useStoreKeys` as-is. 1.0.70 ships the oracle suites + `demo/` stand in the package — living examples per subsystem (`replay/peer-sdk.test.ts` is the Peer contract).
572
+ - Media capture (common2 1.0.66 `Media` namespace / `wenay-common2/media`): CONSUMPTION needs no new hooks — `Media.createAudioSource` / `Media.createVideoSource` return `[emit, listen] & control`, with `replay:true` the `listen` is a normal replay line, so `useReplaySubscribe` / `useReplayFrame` cover it; frames are one `Uint8Array` (40-byte fixed header + raw payload, `Media.decodeMediaFrame`) and follow the existing fold-outside-React rule (canvas/AudioContext via ref). The capture `control` side (`start()` resolving to `'idle'|'requesting'|'live'|'denied'|'no-device'|'error'`, `stop()`, `setDevice`, `listDevices`, stats) is a permission/device/lifecycle state machine — exactly hook-shaped; a `useMediaSource` capture hook + QA card is a recorded target goal (supersedes the earlier "wrap it app-locally" stance). Backpressure defaults live in common2 (audio = lossless queue, video `replay:true` = keep-latest frame recovery); the client-side knobs are the same `policy`/`hint` as any replay line.
573
+
574
+ QA cards 23/24/25/26 (`testUseReact/replayVideo.tsx`, all in-proc): synthetic 10fps jpeg-frame producer on `Replay.replayListen({history, current})`; client A = direct `exposeReplay` remote; client B = simulated slow wire (1 envelope per rateMs) behind `conflateReplay({pending: () => buf.length, highWater: 4, lowWater: 1, keyOf: () => "frame"})`; client C = `archiveReplay` + `openHistory` scrubber; client D = freshness (`staleMs: 2000`, `React.memo` + no tick, mounted inside a local `<StrictMode>`; the flat renders counter under growing frames is the no-per-event-render proof; "stall producer" toggles the emit interval, "new client" remounts by key for the stalled-mount case; card 24 has the same via `staleMs: 2500` on the mirror); client E = pull path (`useReplayFrame` over the direct remote with a wrapped counting `frame()`, pace switch 250ms/1s/3s keeps seq). `window.__replayVideoDemo` is exposed for debugging (wire.setRateMs, stats). Node-verified: slow wire delivered 12/36 envelopes yet converged to the last frame with bounded buffer (coalesced tail recovery); `syncStoreReplay` off() freezes the mirror and `{since}` resubscribe catches up by tail. Card 25 = per-key feed (`useStoreReplayEach` over `exposeStoreReplay`): a dict-of-rows store, producer touches ONE random row per tick, the fold target is a plain Map with per-row cb counters — only the mutated row's counter grows, keyframe/`replace` are the only whole-table expansions, delete arrives as `(key, undefined)`. Card 26 = route hand-off (`useReplayRouteSubscribe` over the same video line): one canvas starts on relay, switches direct/relay by `switchRoute`, and failed replacement keeps the previous route alive. Browser QA of throttling-sensitive behavior needs a VISIBLE tab: hidden-tab timer/effect throttling stalls the producer and delays passive effects (known stand caveat).
575
+
576
+ ## Logs
577
+ Frequent global logger:
578
+ ```
579
+ getLogsApi({limit?, limitPer, varMin?})
580
+ createLogsController({options, state?, onFullChange?, onMiniChange?, onSettingsChange?})
581
+ createLogsControllerState({full?, mini?, settings?})
582
+ logsApi
583
+ PageLogs({update?})
584
+ useMessageEventLogsController({maxVisible?})
585
+ MessageEventLogsView({controller, zIndex?, className?, style?})
586
+ MessageEventLogCard({logs})
587
+ MessageEventLogs({zIndex?}) // compatibility wrapper
588
+ LogsPage({update?})
589
+ useMiniLogsTable({data, onClick?, columnDefs?, defaultColDef?})
590
+ MiniLogsView({controller})
591
+ MiniLogsTable({data, onClick?, columnDefs?, defaultColDef?})
592
+ MiniLogs({data, onClick?}) // compatibility wrapper
593
+ ```
594
+
595
+ React-context logger:
596
+ ```
597
+ LogsProvider
598
+ useLogsContext()
599
+ useLogsTableController()
600
+ LogsTable()
601
+ useLogsNotificationsController()
602
+ LogsNotifications()
603
+ LogsSettings()
604
+ MainPage()
605
+ ```
606
+
607
+ The context logger is a larger UI surface; the global `logsApi` is still the shorter integration point. `createLogsController` is the headless layer for append/limit/settings state; `useMessageEventLogsController` owns the global notification queue/timers/settings, while `MessageEventLogsView` and `MessageEventLogCard` own rendering. `PageLogs`, `MessageEventLogs`, and `LogsPage` remain compatibility UI wrappers. `useLogsTableController` and `useLogsNotificationsController` expose the provider-local table/notification state while `LogsTable` and `LogsNotifications` keep the visual wrappers. Shared logger chrome lives in `src/common/src/logs/logStyles.ts` and is themed through `--logs-*` tokens.
608
+
609
+ Full-page table controller (`useLogsPageTable` -> `LogsPageTableController`): the last logs table
610
+ moved to the controller pattern. Semantics that matter: the grid receives a MOUNT-TIME snapshot of
611
+ the accumulated log map once (`useState` initializer) and afterwards lives on
612
+ `applyTransactionAsync` appends from the mini feed - re-renders do not re-feed `rowData`, so row
613
+ identity is per-append copy exactly as before. The importance filter is ONE method
614
+ (`applyImportanceFilter(min?)`: set -> `setFilterModel` greaterThanOrEqual on `var`, unset ->
615
+ `destroyFilter`), used by both the settings subscription and `onGridReady` (which skips the
616
+ destroy branch - a fresh grid has no filter). Both data subscriptions are `updateBy(obj, cb)`
617
+ WITH a callback: they run imperatively on `renderBy` and never re-render the component.
618
+ `gridProps` is one stable memo - spread it into `AgGridTable`; `fit()` is exposed for the legacy
619
+ `update` prop, which `PageLogs` still honors. QA card 9.
620
+
621
+ ## Cache / Memory / Browser Utilities
622
+ ```
623
+ browserCacheStorage
624
+ localStorageCache
625
+ restoreDates(obj)
626
+ createCacheMapWithStorage(entries, save)
627
+ createCacheMap(entries)
628
+
629
+ ObservableMap<K,V> extends Map // set/delete/clear announce themselves; touch(key?)
630
+ .onChange(cb) -> off // announces an in-place mutation; MapChangeListener<K>
631
+ memoryCache.onDirty(cb) -> off // instance dirty channel (coalesced, async); DirtyListener
632
+ memoryCache.isDirty()
633
+ memoryCache.markDirty(scope?, key?) // manual announce, for plain-Map entries only
634
+
635
+ memorySet(key, data)
636
+ memoryGet(key)
637
+ memoryGetOrCreate(key, def, options?)
638
+ memoryGetById(key, def, id)
639
+ memoryUpdate(key, mutate)
640
+ memoryMarkDirty(key)
641
+ createSearchHistory({key, max?})
642
+ deepMergeWithMap(target, source)
643
+ structEqual(a, b) // deep equality for plain data trees; JSON.stringify-idiom
644
+ // tolerances (undefined props absent, NaN==NaN) WITHOUT
645
+ // its key-order sensitivity; not for Maps/Sets/cycles
646
+ memoryCache
647
+ memoryMaps
648
+
649
+ ArrayPromise({arr, catchF?, thenF?})
650
+ PageVisibilityProvider
651
+ PageVisibilityContext
652
+ setAutoStepForElement(input, {minStep?, maxStep?})
653
+
654
+ useCacheMapPersistence(cache: CacheMap, delay?=300) -> {isDirty(), flush(), save(), reload()}
655
+ ```
656
+
657
+ Layering (2026-07 architecture pass): the persisted maps (`floatingWindowMap`, `mapResiReact`,
658
+ `mapRightMenu`) are DECLARED in the utils leaf `utils/persistedMaps.ts` and re-exported by their
659
+ owning components (FloatingWindow/Resizable/RightMenuStore) - importing `memoryStore`/`memoryCache`
660
+ no longer pulls react-rnd or the Menu tree; only `import type` lines point upward (erased at build).
661
+ Shared order helper `utils/fixedOrder.ts` (`pinFixedOrder`, `movedOrderWithFixed`) is the single
662
+ implementation of the "fixed entries pin to descriptor index" invariant used by columnState,
663
+ ColumnsMenu, and Toolbar (it used to be four inline copies that had to stay byte-identical).
664
+
665
+ Cache helpers are process/browser storage utilities, not React state management. The one React
666
+ entry here is `useCacheMapPersistence` - the documented app contract (`load()` on mount +
667
+ `onDirty -> saveDebounced(delay)`) as a hook. Scope is deliberately exactly that: the
668
+ pagehide/visibilitychange flush backstops stay app-side (they are page-level policy, not
669
+ per-component). Effect identity is `[cache, delay]`; the returned methods are pass-throughs and
670
+ `reload()` is an alias of `load()` - a repeated load MERGES storage on top of the current maps
671
+ (addDataToMap semantics), it is not a reset. QA cards 20/21/25 share one `memoryCache`, so
672
+ several mounted hooks coexist: load() is idempotent-enough (merge) and extra onDirty
673
+ subscribers just debounce the same save.
674
+
675
+ Dirty/save contract: the dirty signal originates in the data layer. The persisted maps are
676
+ `ObservableMap`s, so `set/delete/clear` announce themselves; in-place mutations of stored
677
+ objects are invisible to map methods and are announced with `map.touch(key)` at the commit
678
+ points (FloatingWindow drag/resize stop, FResizableReact resize stop, createUiSlot.setPlace) or,
679
+ app-side, with `memoryUpdate(key, mutate)` / `memoryMarkDirty(key)`. `createCacheMapWithStorage`
680
+ subscribes to the ObservableMaps it owns - the channel is per instance, there is no global
681
+ bus; plain Maps in `arr` stay silent (announce those via `memoryCache.markDirty`). Announcements
682
+ never save anything: scope/key are event metadata, WHAT gets written is decided by the
683
+ save-side serialized-snapshot diff, so a missed announcement degrades to "saved later" and
684
+ an extra one to a no-op write. `isDirty()` is set synchronously; `onDirty` delivery is
685
+ coalesced through a microtask (safe to mutate maps inside render/init paths; not throttled
686
+ in background tabs the way timers are). `memoryCache.load()` ignores its own map events while
687
+ loading and resets dirty after; a save cycle waits out an in-flight load() (a save racing
688
+ a load could overwrite storage with in-memory defaults) and resets the flag at its START
689
+ so a change arriving mid-write survives. Caveat: `flush()` is async - on pagehide
690
+ localStorage writes usually complete but are not guaranteed (Cache API is not); prefer
691
+ visibilitychange->hidden as the primary final save.
692
+
693
+ ## Resize Observer
694
+ ```
695
+ CResizeObserver
696
+ setResizeableElement(el)
697
+ removeResizeableElement(el)
698
+ ObserveID
699
+
700
+ useResizeObserver<T>(onResize) -> {ref, element()}
701
+ useElementSize<T>() -> {ref, element(), width, height, getSize()}
702
+ ```
703
+
704
+ Use the class/imperative pair only when a component must participate in the shared resize
705
+ observer map. The hooks are the React-shaped entry over the SAME module singleton (one native
706
+ `ResizeObserver` per app): `useResizeObserver` returns a callback ref (attach/detach re-observes;
707
+ React null-call on unmount unsubscribes) and takes `onResize` through a ref - new identity
708
+ neither resubscribes nor is missed. The native observer fires once right after `observe`, so the
709
+ first measurement arrives without an explicit initial read. `useElementSize` folds that into
710
+ rounded, equality-guarded `{width, height}` state (a no-op resize does not re-render) plus a live
711
+ `getSize()` getter (exact floats, no render wait). `setResizeableElement`'s auto-shrink path is
712
+ untouched and still class-driven (ParamsEditor inputs). QA card 19 drives both hooks.
713
+
714
+ ## Styles
715
+ ```
716
+ tokens // TS mirror for inline styles and z-index
717
+ GridStyleDefault()
718
+ StyleGridDefault
719
+ StyleCSSHeadGridEdit(name, rules)
720
+ StyleCSSHeadGrid()
721
+ AgGridClassRule<T>
722
+ ```
723
+
724
+ Style entry points:
725
+ - `src/style/tokens.css` - CSS custom properties shipped to consumers.
726
+ - `src/common/src/styles/tokens.ts` - TS mirror for inline styles, modal z-index, and ag-grid theme params. Values must stay aligned with `tokens.css`.
727
+ - `src/style/style.css` - shared component classes and token consumers.
728
+ - `src/style/menuRight.css` - right-menu classes and outline-demo CSS.
729
+ - `src/common/src/styles/styleGrid.ts` and `src/common/src/grid/agGrid4/theme.ts` - ag-grid theme setup from `tokens.grid`.
730
+ - QA stand: `npm run testReact -- --host 127.0.0.1 --port 3002`, entry `src/common/testUseReact/qa.tsx`.
731
+
732
+ Current tokenized prefixes:
733
+ - `--color-*` for base palette.
734
+ - `--menu-*` for mouse context-menu and right-menu visuals.
735
+ - `--wnd-*` for `FloatingWindow` chrome.
736
+ - `--dlg-*` for `SettingsDialog` chrome.
737
+ - `--tb-*` for toolbar chrome.
738
+ - `--logs-*` for global/context logger chrome.
739
+ - `--cols-menu-*` for compact `ColumnsMenu/MenuStrip` chrome.
740
+ - `--cols-dots-*` for `ColumnDots` chrome.
741
+ - `--cols-card-*` for `CardList` chrome.
742
+ - `--wenay-z-modal` for modal/overlay stacking.
743
+
744
+ Normalization rule: new shared CSS should first try an existing token. Add a new token only when a value is reused by a shared primitive or is expected to be theme-overridden by apps. One-off app/demo styles should stay in the demo/app wrapper, not in library tokens. Do not delete a default style/class without a replacement class/token path and changelog entry; visually broken defaults are treated as a compatibility break.
745
+
746
+ Open normalization candidates:
747
+ - `src/style/style.css`: `.msTradeAlt`, `.msTradeActive`, `.newButtonSimple`, `.toIndicatorMenuButton:hover`, submit-button green, and several toolbar row hover/drag literals still use raw colors.
748
+ - `src/common/src/grid/columnState/*`: compact menu/dots/card visuals now use `.wenayColumnGrid*`, `.wenayColsMenu*`, `.wenayColDots*`, `.wenayCardList*` plus `--cols-grid-*`, `--cols-menu-*`, `--cols-dots-*`, and `--cols-card-*`; further changes here should be visual QA only, not a new default palette.
749
+ - `src/common/src/components/ParamsEditor.tsx` and `src/common/src/components/Input.tsx`: if these stay public primitives, define default class/token contracts instead of component-owned visual styling.
750
+ - `src/common/src/styles/commentaryStyles.css`: standalone `.commentary` CSS is not imported by the root style bundle; either import/tokenize it if still used, or mark it as a local component concern.
751
+
752
+ Recently normalized: mouse context-menu item colors through `--menu-*`, `--menu-outline-color` for `OutlineDragDemo`, `--logs-*` for logger chrome, `--dlg-scrim` in `ModalProvider`, compact `ColumnsMenu/MenuStrip` visuals through `.wenayColsMenu*` / `--cols-menu-*`, card-29 mobile primitives through `--cols-dots-*` / `--cols-card-*`, and createColumnGrid overlay through `.wenayColumnGrid*` / `--cols-grid-*`.
753
+
754
+ ## Cleanup Inventory
755
+
756
+ Do not delete a public export just because it looks unused inside this repo. External apps may import it. For cleanup, first move an item into this inventory, then decide in a separate breaking version whether it remains public, moves to a demo namespace, or is removed.
757
+
758
+ Suspicious but still public:
759
+ - `OutlineDragDemo`, `RightMenuDemo`, `ChartDemo` - demo/test-style exports. They are useful for the QA stand, but look like demo surface rather than core API.
760
+ - `StickerMenu` - exported from `components/Menu`; visually app-specific and should probably become an app wrapper or be documented as an example component.
761
+ - `logsApi` global logger and the context logger (`LogsProvider`, `LogsTable`, `LogsNotifications`, `LogsSettings`, `MainPage`) overlap. Keep both for now; document one as the short integration path and the other as the larger UI surface.
762
+ - Chart engine primitives (`DataSet`, `Panel`, `Renderer`, `Interaction`, `ChartEngine`, etc.) are very low-level. They are public through `chartEngineReact.tsx`; product apps should wrap them before use.
763
+ - `src/common/src/myChart/chartEngine/chartEngine.ts` is a reference copy next to the public React engine. It is not exported from root; treat it as suspicious maintenance debt, not as public API.
764
+ - `StyleCSSHeadGridEdit` and `StyleCSSHeadGrid` mutate `<head>` directly. They are exported and can have consumers, but new grid styling should prefer ag-grid theme params and tokens.
765
+
766
+ Added by the 2026-07-09 architecture audit (evidence in the audit report / `doc/target/my.md`):
767
+ - `menuR.tsx` (`createRightClickMenu` / `MenuR`) - CONFIRMED dead: no runtime consumer anywhere (the test that supposedly covers it imports `RightMenu.tsx`); re-exported twice from `api.tsx` (`export *` + `kit.menu.rightClick`). Candidate for removal in a deliberate breaking version; its gesture code duplicates `menuMouse.tsx:342-380`.
768
+ - `logsContext.tsx` - a full PARALLEL logs stack (own `LogEntry` shape divergent from `logsController`, raw unguarded `localStorage`, own settings/notifications/table) exported from the barrel next to `logs.tsx`. Decide: deprecate or converge; new code should use `logsApi`/`createLogsController`.
769
+ - `MyChartEngine` - a hardcoded DEMO (random data + setInterval inside `useEffect`, no props but `style`) exported as public API from `chartEngineReact.tsx`; `generateIncrementalData` (Math.random demo util) is public too. Should move to demo namespace or gain a real props contract.
770
+ - ~~`getLogsApi<T>()` shared module state~~ FIXED (A2, v1.0.41): every call now builds its own map/mini/settings (optional `settingsKey` persists per instance); the global `logsApi` explicitly injects the legacy shared state.
771
+ - ~~`FloatingWindow` `onCLickClose` typo~~ ALIASED (A10, v1.0.42): `onClickClose` added, typo prop is a deprecated alias; REMOVAL of the alias still needs a breaking pass.
772
+ - `DragArea` - `@deprecated` (A7, v1.0.42): no consumers; unique semantics kept as-is (see Drag / Resize Low Level). Removal in a deliberate breaking version.
773
+ - `Button` `keySave` prop - deprecated alias of `keyForSave` (A10, v1.0.42); removal in a breaking pass.
774
+ - `contextMenu` raw `map`/legacy-queue path is test-only-live (populated only by `contextMenuStats.test.tsx`); the `bb`/`map.clear()` reopening invariant itself is intentional and stays.
775
+
776
+ ## Charts
777
+ Canvas chart:
778
+ ```
779
+ createChartCanvas(config) -> IChartCanvas
780
+ ChartDemo()
781
+ ```
782
+
783
+ Chart engine:
784
+ ```
785
+ createDataSet(params)
786
+ createDataModel()
787
+ createPanelManager()
788
+ createRenderer()
789
+ createInteraction(...)
790
+ createChartEngine(canvas)
791
+ generateIncrementalData(...)
792
+ MyChartEngine
793
+ ```
794
+
795
+ The chart engine exports many internal interfaces (`DataPoint`, `DataSet`, `Panel`, `Renderer`, `Transform`,
796
+ `Interaction`, `ChartEngine`). Treat them as low-level until an app wrapper fixes product-level behavior.
797
+
798
+ ## Deprecated Naming Smells
799
+ ```
800
+ Get* // usually a factory from older style; prefer create* or use*
801
+ *FuncJSX // imperative JSX store; prefer React context/controller
802
+ *2 / *3 // version suffix; document the intended canonical one in brief
803
+ removed old grid update names -> applyGridRows / agGrid4
804
+ ```
805
+
806
+ Do not add new generic utilities with app words in their signature. For example, a column primitive should accept
807
+ `names` and `apply`, while a product wrapper decides group ids, column ids, and labels.