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.
- package/README.md +14 -14
- package/doc/EXAMPLE_USAGE.md +969 -969
- package/doc/PROJECT_FUNCTIONALITY.md +403 -403
- package/doc/PROJECT_RULES.md +27 -27
- package/doc/WENAY_REACT2_RENAMES.md +170 -170
- package/doc/changes/v1.0.35.md +18 -18
- package/doc/changes/v1.0.38.md +37 -37
- package/doc/changes/v1.0.39.md +23 -23
- package/doc/changes/v1.0.40.md +14 -14
- package/doc/changes/v1.0.41.md +35 -35
- package/doc/changes/v1.0.42.md +26 -26
- package/doc/changes/v1.0.43.md +10 -10
- package/doc/changes/v1.0.44.md +14 -14
- package/doc/changes/v1.0.45.md +8 -8
- package/doc/changes/v1.0.46.md +11 -11
- package/doc/changes/v1.0.47.md +9 -9
- package/doc/changes/v1.0.48.md +8 -0
- package/doc/examples/README.md +4 -4
- package/doc/examples/peer-call-media.tsx +7 -7
- package/doc/examples/stand.tsx +5 -5
- package/doc/native.md +37 -37
- package/doc/progress/README.md +13 -13
- package/doc/progress/architecture-fix-queue.md +74 -74
- package/doc/progress/column-state-present-gate.md +28 -28
- package/doc/progress/common2-adoption-1.0.73.md +28 -28
- package/doc/progress/common2-adoption-1.0.74.md +24 -24
- package/doc/progress/hook-controller-opportunities.md +363 -363
- package/doc/progress/hook-extraction-audit.md +195 -195
- package/doc/progress/public-surface-normalization.md +351 -351
- package/doc/progress/qa-stand-walkthrough-2026-07-12.md +62 -0
- package/doc/progress/stand-as-examples-audit.md +20 -20
- package/doc/progress/style-system-normalization.md +121 -121
- package/doc/target/README.md +32 -32
- package/doc/target/my.md +122 -122
- package/doc/wenay-react2-rare.md +807 -807
- package/doc/wenay-react2.md +541 -541
- package/lib/common/demo/peerMedia.js +6 -1
- package/lib/common/src/logs/logsController.js +2 -2
- package/lib/common/testUseReact/qa.js +3 -3
- package/lib/style/menuRight.css +19 -19
- package/lib/style/style.css +23 -23
- package/lib/style/tokens.css +184 -184
- package/package.json +1 -1
- package/doc/wenay-react2-1.0.8.tgz +0 -0
package/doc/wenay-react2.md
CHANGED
|
@@ -1,541 +1,541 @@
|
|
|
1
|
-
# wenay-react2 - BRIEF cheat sheet (canonical UI/controller API)
|
|
2
|
-
|
|
3
|
-
> Root import: `import { ... } from "wenay-react2"`.
|
|
4
|
-
> Notation: `name(args) -> ret`. JSX examples show the intended public path, not every prop.
|
|
5
|
-
> Short controller-style names are canonical. Removed names are recorded in
|
|
6
|
-
> **WENAY_REACT2_RENAMES.md** for migration only; old aliases are not exported.
|
|
7
|
-
|
|
8
|
-
## Standard
|
|
9
|
-
```
|
|
10
|
-
Hooks that own lifecycle/state return a small controller:
|
|
11
|
-
api.props / api.bind // props to spread on an element
|
|
12
|
-
api.open() / close() / set() // modal/menu-like state
|
|
13
|
-
api.update() / remove() / clean() // data buffers
|
|
14
|
-
api.fit() / flush() / sync() // ag-grid lifecycle
|
|
15
|
-
|
|
16
|
-
Components receive controllers when state must outlive the component.
|
|
17
|
-
Library primitives never know app-specific terms, domain objects, or group policy.
|
|
18
|
-
Put app-specific layout/build rules in an app wrapper above the primitive.
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
## Render Memory
|
|
22
|
-
```
|
|
23
|
-
updateBy(obj) / useUpdateBy(obj) // subscribe current component to renderBy(obj)
|
|
24
|
-
updateBy(obj, cb) // imperative callback instead of a re-render;
|
|
25
|
-
// cb goes through a ref - inline identity is fine
|
|
26
|
-
renderBy(obj, ms?) // emit render for subscribers
|
|
27
|
-
renderByRevers(obj, ms?, reverse?=true) // reverse/last affect ONLY React subscribers;
|
|
28
|
-
renderByLast(obj, ms?) // updateBy(obj, cb) callbacks always all run first
|
|
29
|
-
|
|
30
|
-
const state = { count: 0 }
|
|
31
|
-
const api = createUpdateApi(state)
|
|
32
|
-
api.use() // hook subscription
|
|
33
|
-
api.emit(ms?) // renderBy(state)
|
|
34
|
-
api.on(obj => {}) -> off
|
|
35
|
-
|
|
36
|
-
const api2 = useUpdateByApi(state) // hook + controller in one call
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
Persistent process memory:
|
|
40
|
-
```
|
|
41
|
-
memoryGetOrCreate(key, def, {abs?, deepAutoMerge?, reversDeep?}) -> def-or-stored
|
|
42
|
-
memoryGetById(key, def, id) -> stored only while id is the same
|
|
43
|
-
memorySet(key, data)
|
|
44
|
-
memoryGet(key)
|
|
45
|
-
memoryUpdate(key, mutate) -> cur? // mutate + rerender + announce in one call
|
|
46
|
-
memoryMarkDirty(key) // announce an in-place mutation of a memoryGetOrCreate object
|
|
47
|
-
createSearchHistory({key, max?}) // reusable small persisted search history controller
|
|
48
|
-
memoryMaps // rnd / resize / other maps
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
Persistence contract (memoryCache): the library NEVER writes storage by itself. The persisted maps
|
|
52
|
-
(floatingWindowMap, mapResiReact, mapRightMenu, memoryProps) are `ObservableMap`s - set/delete/clear
|
|
53
|
-
announce themselves, in-place mutations are announced at the commit points (drag/resize stop,
|
|
54
|
-
menu drag end, setPlace). memoryCache observes the maps it owns; the app owns the write policy:
|
|
55
|
-
```
|
|
56
|
-
memoryCache.load() // once on start; remembers the saved snapshot
|
|
57
|
-
memoryCache.onDirty((scope?, key?) => ...) -> off // dirty channel (coalesced, async)
|
|
58
|
-
memoryCache.saveDebounced(ms?) / save() / flush() // write only payloads that differ from the snapshot
|
|
59
|
-
memoryCache.isDirty() // cheap hint, e.g. a beforeunload guard
|
|
60
|
-
|
|
61
|
-
memoryCache.load()
|
|
62
|
-
const off = memoryCache.onDirty(() => memoryCache.saveDebounced(800))
|
|
63
|
-
document.addEventListener("visibilitychange",
|
|
64
|
-
() => { if (document.visibilityState == "hidden") void memoryCache.flush() })
|
|
65
|
-
window.addEventListener("pagehide", () => { void memoryCache.flush() }) // backstop: flush is async
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
The load + dirty->saveDebounced part of that contract as one hook (React components):
|
|
69
|
-
```
|
|
70
|
-
const persistence = useCacheMapPersistence(memoryCache, delayMs?=300)
|
|
71
|
-
persistence.isDirty(); persistence.flush(); persistence.save(); persistence.reload() // reload = load() alias
|
|
72
|
-
```
|
|
73
|
-
The pagehide/visibility flush backstops stay app-side (see above) - the hook deliberately does not own them.
|
|
74
|
-
|
|
75
|
-
## Outside Click / Buttons
|
|
76
|
-
```
|
|
77
|
-
const outside = useOutside({onOutside, enabled?})
|
|
78
|
-
<div {...outside.props} />
|
|
79
|
-
outside.enable()
|
|
80
|
-
outside.disable()
|
|
81
|
-
outside.contains(event.target)
|
|
82
|
-
|
|
83
|
-
<OutsideClickArea outsideClick={close} status={open}>...</OutsideClickArea>
|
|
84
|
-
|
|
85
|
-
<Button button={<button>Open</button>} outClick keyForSave?>{...}</Button> // keySave = deprecated alias
|
|
86
|
-
<OutsideButton button={...}>{...}</OutsideButton>
|
|
87
|
-
<HoverButton button={...}>{...}</HoverButton>
|
|
88
|
-
<AbsoluteButton button={...}>{...}</AbsoluteButton>
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
## Element Size / Resize Observer
|
|
92
|
-
```
|
|
93
|
-
const box = useResizeObserver<HTMLDivElement>(() => onResize()) // shared singleton observer
|
|
94
|
-
<div ref={box.ref} />; box.element()
|
|
95
|
-
|
|
96
|
-
const size = useElementSize<HTMLDivElement>() // "want the size -> get the value/method"
|
|
97
|
-
<div ref={size.ref} />
|
|
98
|
-
size.width; size.height // state, rounded, no-op resize does not re-render
|
|
99
|
-
size.getSize() // live getter (exact, no render wait)
|
|
100
|
-
|
|
101
|
-
setResizeableElement(el) / removeResizeableElement(el) // legacy imperative auto-shrink path, unchanged
|
|
102
|
-
```
|
|
103
|
-
Both hooks ride the same module-level `CResizeObserver` singleton (one native `ResizeObserver` for the whole app). QA card 19.
|
|
104
|
-
|
|
105
|
-
## Drag / Floating Windows
|
|
106
|
-
```
|
|
107
|
-
const drag = useDraggableApi({initialPosition, holdMs?, onDragStart?, onDragEnd?, onMove?, trackState?})
|
|
108
|
-
<div {...drag.bind} style={{transform: `translate(${drag.position.x}px, ${drag.position.y}px)`}} />
|
|
109
|
-
drag.setPosition({x, y})
|
|
110
|
-
drag.resetPosition()
|
|
111
|
-
drag.cancelDrag()
|
|
112
|
-
// imperative path: onMove(p) fires per move tick (through a ref); trackState:false stops
|
|
113
|
-
// per-tick re-renders - position lives only in drag.positionRef/onMove (DragBox is this shape)
|
|
114
|
-
|
|
115
|
-
const r = useReorder({order, commit, move?, canDrag?, preview?, holdMs?}) // mini reorder-by-drag
|
|
116
|
-
<div ref={r.listRef}>{order.map(k => { // children 1:1 with order
|
|
117
|
-
const it = r.item(k) // it.dragging / it.active
|
|
118
|
-
return <div key={k} {...it.props} style={{...own, ...it.style}} /> // ONE commit(next) on drop
|
|
119
|
-
})}</div>
|
|
120
|
-
|
|
121
|
-
const b = useReorderBoard({columns: [{key, items}], commit, // drag between vertical columns
|
|
122
|
-
canDrag?, holdMs?, onDragStart?, onDragMove?, onOverChange?, onDragEnd?})
|
|
123
|
-
<div ref={b.columnRef('todo')}>{items.map(k => ...b.item(k)...)}</div> // one div per column, any count
|
|
124
|
-
b.over // {col, index} | null - live target
|
|
125
|
-
// column gravity is YOUR CSS: justify-content flex-start packs up, flex-end packs down
|
|
126
|
-
|
|
127
|
-
<FloatingWindow keyForSave="tool" size={{width: 320, height: 240}} header={<div>Tool</div>}
|
|
128
|
-
onClickClose={() => setOpen(false)}> {/* onCLickClose (typo) = deprecated alias */}
|
|
129
|
-
<Panel />
|
|
130
|
-
</FloatingWindow>
|
|
131
|
-
|
|
132
|
-
const wnd = useFloatingWindowController({keyForSave: "custom-tool", size: {width: 320, height: 240}})
|
|
133
|
-
wnd.position // {x,y}; bind wnd.onHeaderMouseDown/onHeaderTouchStart for custom chrome
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
## Modal / Input
|
|
137
|
-
```
|
|
138
|
-
<ModalProvider>
|
|
139
|
-
<App />
|
|
140
|
-
</ModalProvider>
|
|
141
|
-
|
|
142
|
-
const modal = useModal()
|
|
143
|
-
modal.open(<Dialog />)
|
|
144
|
-
modal.set(<Dialog />)
|
|
145
|
-
modal.close()
|
|
146
|
-
modal(null) // callable shortcut for clearing
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
Input helpers:
|
|
150
|
-
```
|
|
151
|
-
const text = useTextInputPanel({callback: txt => {}, txt: ""}) // headless value + submit API
|
|
152
|
-
const file = useFileInputPanel({callback: file => {}}) // headless file + submit API
|
|
153
|
-
<TextInputPanel callback={txt => {}} name="Name" txt="" /> // thin visual wrapper over hook
|
|
154
|
-
<FileInputPanel callback={file => {}} name="File" /> // thin visual wrapper over hook
|
|
155
|
-
<TextInputModal callback={...} outClick={modal.close} />
|
|
156
|
-
<FileInputModal callback={...} outClick={modal.close} />
|
|
157
|
-
<FreeModal outClick={modal.close} size={{width: 400, height: 260}}>...</FreeModal>
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
## Settings Dialog
|
|
161
|
-
```
|
|
162
|
-
<SettingsDialog trigger={<span>settings</span>} sections={[{key, name, render, children?, parentKey?, searchText?, keywords?}]} defaultSection? /> // searchable tree + persisted search history; history closes when search focus leaves
|
|
163
|
-
const settings = useSettingsDialogController({sections, defaultSection?}) // open/search/tree/history/resize actions for custom chrome
|
|
164
|
-
registerSettingsSection({key, name, render, parentKey?, searchText?, keywords?}) -> unregister
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
## UI Slot
|
|
168
|
-
```
|
|
169
|
-
const slot = createUiSlot({key, places: {top: "Top bar", side: "Sidebar"}, def: "top"})
|
|
170
|
-
|
|
171
|
-
<slot.Slot place="top">{content}</slot.Slot> // each mount point decides by itself
|
|
172
|
-
<slot.PlacementSetting /> // segmented place switcher; setPlace marks memoryCache dirty
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
## Toolbar
|
|
176
|
-
```
|
|
177
|
-
const tb = createToolbar({key, items: [{key, title, icon?, short?, render?, onClick?, defaultVisible?, fixed?}], def?, settingsItem?, resetItem?, source?, sourceMode?})
|
|
178
|
-
|
|
179
|
-
<tb.Bar settings reset? /> // the live bar; settings adds a gear; reset is hidden by default; item moves animate
|
|
180
|
-
<tb.Settings /> // the PURE config editor - same element drops into a settings section
|
|
181
|
-
tb.api.useConfig() / getConfig() / setConfig(next) / setOrder(order) / show(key,on) / setDensity(key) / reset()
|
|
182
|
-
tb.api.showSettings(on) / showReset(on) // pseudo-controls visibility
|
|
183
|
-
tb.api.useItems() // headless bar: ordered visible [{item, density, content}] - custom markup
|
|
184
|
-
tb.api.onChange.on(cfg => ...) -> off // fires on every edit
|
|
185
|
-
tb.api.dispose() // release the external-source subscription (HMR/remounts); persisted config stays
|
|
186
|
-
|
|
187
|
-
registerToolbarDensity({key, name, renderItem?}) -> unregister // built-ins: 'icon', 'label'
|
|
188
|
-
toolbarItemIcon(item) // the item's icon, or its first letters as a text pseudo-icon
|
|
189
|
-
```
|
|
190
|
-
Config `{order, visible, density}` is serializable and persisted like createUiSlot
|
|
191
|
-
(memoryGetOrCreate -> memoryCache, the app owns the write policy). Items added in an app update are merged
|
|
192
|
-
into a stale persisted config (appended, default-visible), removed ones are ignored. `icon` is
|
|
193
|
-
optional: in icon density an icon-less item renders the first letters of its short/title.
|
|
194
|
-
The gear and reset button are pseudo-items: `settingsItem: {title?, icon?}` and `resetItem` re-skin them,
|
|
195
|
-
and the editor has separated toggle rows for them (`visible['__settings']` / `visible['__reset']`, no order slots - they always sit at the bar edge). Settings is visible by default; reset is hidden by default unless `resetItem.defaultVisible: true` or `api.showReset(true)` enables it. `resetItem: false` removes the reset feature.
|
|
196
|
-
|
|
197
|
-
`source?` makes the toolbar a VIEW over an external `UiListSource`. Default `sourceMode:"orderVisible"`
|
|
198
|
-
means the source owns item order and item visibility, so one config can drive a grid, its icon menu
|
|
199
|
-
AND the toolbar together (`columnState.api.listSource`, QA card 31). `sourceMode:"order"` shares only
|
|
200
|
-
the source-key order; item membership, density, pseudo-controls, and extra non-source item positions
|
|
201
|
-
stay in the toolbar store (QA card 30). Without `source` the toolbar keeps its own store (standalone mode).
|
|
202
|
-
|
|
203
|
-
## Callback Hub
|
|
204
|
-
```
|
|
205
|
-
const hub = createCallbackHub<[Args]>(emit => api.onX(emit)) // one slot -> many subscribers
|
|
206
|
-
hub.on(cb) -> off
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
## Params
|
|
210
|
-
```
|
|
211
|
-
<ParamsEditor
|
|
212
|
-
params={params}
|
|
213
|
-
onChange={next => setParams(next)}
|
|
214
|
-
onExpand={next => setParams(next)}
|
|
215
|
-
expandStatus?
|
|
216
|
-
expandStatusLvl?
|
|
217
|
-
/>
|
|
218
|
-
|
|
219
|
-
const controller = useParamsEditorController({params, onChange, onExpand?})
|
|
220
|
-
|
|
221
|
-
<ParamsEdit params={params} onSave={next => save(next)} />
|
|
222
|
-
<ParamRow name="Name">...</ParamRow>
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
## Menu
|
|
226
|
-
```
|
|
227
|
-
type MenuItem = { name, actionKey?, onClick?, next?, func?, status? } | false | null | undefined
|
|
228
|
-
|
|
229
|
-
<Menu data={items} coordinate={{x: 0, y: 0}} />
|
|
230
|
-
|
|
231
|
-
<contextMenu.Layer>{children}</contextMenu.Layer>
|
|
232
|
-
contextMenu.openAt(event, [{name: "Copy", actionKey: "grid.copy", onClick: copy}], {source: "grid"})
|
|
233
|
-
contextMenu.stats.getSnapshot() // local counters: opens, source/layer usage, actionTotals, keyed actions
|
|
234
|
-
contextMenu.close()
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
Use `contextMenu.openAt(e, items, {source?})` for new right-click integrations. `contextMenu.map` still exists as a legacy Layer queue, but it is not the recommended path for new code. `contextMenu.stats` is an in-memory diagnostics API; it never persists data, never reports over the network, and records per-action counters only for explicit `actionKey` values. Unkeyed items are counted in aggregate totals without storing labels.
|
|
238
|
-
|
|
239
|
-
`Menu` keeps the hovered/open item in internal React state. `status` is only an initial open hint and a compatibility field passed to custom renderers; menu objects are not mutated.
|
|
240
|
-
|
|
241
|
-
Floating right menu:
|
|
242
|
-
```
|
|
243
|
-
<DropdownMenu elements={items} trigger={iconOrRender} classNames={...} styles={...} />
|
|
244
|
-
const menu = useRightMenuController({elements, keyForSave?})
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
`DropdownMenu` is a floating action menu with caller-owned trigger/content styling, not the main context-menu primitive. `useRightMenuController` exposes the same open/fixed/select/submenu/drag state for custom views; `DropdownMenu` remains the compatible visual wrapper.
|
|
248
|
-
|
|
249
|
-
## agGrid4
|
|
250
|
-
Plain declarative rows:
|
|
251
|
-
```
|
|
252
|
-
<AgGridTable<Row> rowData={rows} columnDefs={cols} />
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
Buffered mirror table:
|
|
256
|
-
```
|
|
257
|
-
const grid = useAgGrid<Row>({getId: row => row.id})
|
|
258
|
-
<AgGridTable<Row> controller={grid} columnDefs={cols} />
|
|
259
|
-
|
|
260
|
-
grid.update({newData: rows})
|
|
261
|
-
grid.remove([{id}])
|
|
262
|
-
grid.clean()
|
|
263
|
-
grid.fit()
|
|
264
|
-
grid.flush()
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
Shared module store:
|
|
268
|
-
```
|
|
269
|
-
export const mainTable = createGridBuffer<Row>({getId, externalBuffer})
|
|
270
|
-
|
|
271
|
-
const grid = useAgGrid({core: mainTable})
|
|
272
|
-
<AgGridTable<Row> controller={grid} columnDefs={cols} />
|
|
273
|
-
```
|
|
274
|
-
|
|
275
|
-
Overlay patches over React-owned `rowData`:
|
|
276
|
-
```
|
|
277
|
-
const core = createGridBuffer<Row>({getId, mode: "overlay", pushDefaults: {add: false}})
|
|
278
|
-
const grid = useAgGrid({core})
|
|
279
|
-
|
|
280
|
-
<AgGridTable<Row> controller={grid} rowData={rows} columnDefs={cols} getRowId={p => getId(p.data)} />
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
Dynamic columns are pure names + lifecycle replay:
|
|
284
|
-
```
|
|
285
|
-
const columns = createColumnBuffer<Row>()
|
|
286
|
-
|
|
287
|
-
columns.api.setNames(["a", "b"])
|
|
288
|
-
columns.control.attach(api, {
|
|
289
|
-
apply: ({api, names}) => api.setGridOption("columnDefs", buildColumnDefs(names)),
|
|
290
|
-
})
|
|
291
|
-
columns.api.apply()
|
|
292
|
-
columns.control.detach()
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
`createColumnBuffer()` knows nothing about groups, `columnDefs`, business names, or where columns live.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
## columnState
|
|
299
|
-
A persisted column layer over a keyed column set - order / visibility / width / sort / filter in a
|
|
300
|
-
standalone config store, with an OPTIONAL two-way ag-grid adapter. Mobile card views consume the
|
|
301
|
-
SAME config with no ag-grid at all. agGrid4 wrappers are never modified: this is the app-level
|
|
302
|
-
column wrapper `WRAPPER.md` postulates, packaged as a primitive.
|
|
303
|
-
```
|
|
304
|
-
const cs = createColumnState({key, columns: [{key, title, short?, icon?, group?, fixed?, defaultVisible?, cardRole?}], def?, saveMs?})
|
|
305
|
-
|
|
306
|
-
cs.columns // the descriptors (UI renders from these + config)
|
|
307
|
-
cs.api.useConfig() / getConfig() / setConfig(next) / reset()
|
|
308
|
-
cs.api.show(key, on) / move(order) / setSort({key, dir} | null) / toggleSort(key) // asc->desc->off
|
|
309
|
-
cs.api.visibleKeys() // keys to render, in order (group-gated)
|
|
310
|
-
cs.api.onChange.on(cfg => ...) -> off
|
|
311
|
-
cs.api.usePresent() / isPresent(key) / setPresent(keys | null) // live-grid presence
|
|
312
|
-
cs.api.getPresentGate() / setPresentGate(keys | null) // app runtime availability gate, not persisted
|
|
313
|
-
cs.api.listSource // {order, visible} slice as a Toolbar `source`
|
|
314
|
-
```
|
|
315
|
-
Config `{v, order, visible, width, sort, filter, groups}` is serializable and persisted like
|
|
316
|
-
createToolbar (memoryGetOrCreate(key) -> memoryCache, the app owns the write policy); a stale config migrates
|
|
317
|
-
softly (unknown keys dropped, new columns appended default-visible, `fixed` pinned). `sort` is
|
|
318
|
-
STICKY: independent of visibility/selection, may point at a hidden or grid-absent column, changes
|
|
319
|
-
only by an explicit toggle/header click.
|
|
320
|
-
|
|
321
|
-
Two-way ag-grid adapter (opt-in, agGrid4 untouched):
|
|
322
|
-
```
|
|
323
|
-
<AgGridTable columnDefs={cols} autoSizeColumns={false}
|
|
324
|
-
onGridReady={e => cs.grid.attach(e.api)} // restore saved layout, then watch the grid
|
|
325
|
-
onGridPreDestroyed={() => cs.grid.detach()} /> // config survives remounts
|
|
326
|
-
```
|
|
327
|
-
Grid drag/resize/hide/sort/filter fold back into the config; UI edits apply to the grid; a column
|
|
328
|
-
removed from the live grid (dynamic defs) keeps its config entry and reads `isPresent==false`.
|
|
329
|
-
|
|
330
|
-
Icon menu - a 1:1 grid mirror (reorder in the grid <-> reorder the buttons):
|
|
331
|
-
```
|
|
332
|
-
<ColumnsMenu state={cs} compact? marks? tail? onItem? onTail? reorder? /> // default click = toggle visibility
|
|
333
|
-
<MenuStrip items={[{key, title, state:'on'|'off'|'disabled', marks?, fixed?}]} onItem? onMove? tail? compact? />
|
|
334
|
-
```
|
|
335
|
-
`ColumnsMenu` binds `MenuStrip` - a presentation-only layer that reports clicks/drags but never
|
|
336
|
-
interprets them - to the config. `compact` = icon-only buttons; an item with no icon shows its
|
|
337
|
-
first letters. `tail` = trailing non-column buttons (a table-standards cycler etc.). For new
|
|
338
|
-
settings-integrated toolbar/grid surfaces prefer `createToolbar({source: cs.api.listSource})`;
|
|
339
|
-
keep `ColumnsMenu` for compact button strips or custom presentation-only menus.
|
|
340
|
-
|
|
341
|
-
Mobile (no ag-grid): dots selector + card rows over the same config:
|
|
342
|
-
```
|
|
343
|
-
<ColumnDots state={cs} max?=8 /> // track of marks; dots = shown columns; tap empty=add,
|
|
344
|
-
// drag=LIVE replace (every empty mark crossed swaps the shown
|
|
345
|
-
// column immediately + small label names it - column search
|
|
346
|
-
// by finger), swipe up=remove, tap dot=select, sort cycles
|
|
347
|
-
<CardList<Row> state={cs} data={rows} getId? renderValue? /> // visible cols -> card fields; cardRole:'title'/'accent'
|
|
348
|
-
```
|
|
349
|
-
|
|
350
|
-
Default wrapper for repeated grid/menu/mobile use:
|
|
351
|
-
```
|
|
352
|
-
const cg = createColumnGrid<Row>({
|
|
353
|
-
key: "orders.columns",
|
|
354
|
-
columnDefs: cols, // ColumnMeta inferred from colId/field/headerName
|
|
355
|
-
data: rows, // optional default; View props can override
|
|
356
|
-
getId: r => r.id,
|
|
357
|
-
autoSizeOnColumnCountChange: true, // optional fit when visible column count changes
|
|
358
|
-
columns: [{key: "name", fixed: true, cardRole: "title"}], // optional overrides
|
|
359
|
-
})
|
|
360
|
-
|
|
361
|
-
<cg.View mode="table" /> // default controls="auto" -> dots overlay
|
|
362
|
-
<cg.View mode="cards" data={otherRows} />
|
|
363
|
-
<cg.Table rowData={rows} getRowId={p => p.data.id} />
|
|
364
|
-
<cg.Menu compact />
|
|
365
|
-
<cg.Dots /> // no wrapper max; defaults to all columns
|
|
366
|
-
<cg.Toolbar settings />
|
|
367
|
-
<cg.Settings />
|
|
368
|
-
```
|
|
369
|
-
`createColumnGrid` returns `{state, toolbar, api, grid, tableProps, Table, Menu, Dots, Cards, Toolbar, Settings, View, dispose}`; `dispose()` releases factory-lifetime subscriptions (config onChange, toolbar source, pending fit) without touching the persisted config. The columnState BARREL stays ag-grid-free; `createColumnGrid` ships from its own module (both are exported from the package root).
|
|
370
|
-
Its `Table`/`tableProps()` attach/detach the columnState grid adapter automatically and default
|
|
371
|
-
`autoSizeColumns=false` so restored widths are not overwritten. `autoSizeOnColumnCountChange` is
|
|
372
|
-
separate and only calls `sizeColumnsToFit()` when the visible column count changes. `useColumnGrid(opts)`
|
|
373
|
-
is the same controller captured once for component-local setups; keep module-level `createColumnGrid`
|
|
374
|
-
when the controller must survive route/component remounts.
|
|
375
|
-
|
|
376
|
-
QA cards 28 (grid layer + F5 restore), 29 (mobile dots + cards), 30 (toolbar icon menu + grouped sub-column mode button), 31 (Toolbar over the same config), 32 (createColumnGrid wrapper + dots driving table/cards).
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
## Observe React Adapter
|
|
380
|
-
`wenay-common2` owns the store/listen/RPC primitives. `wenay-react2` owns React lifecycle hooks around them.
|
|
381
|
-
|
|
382
|
-
```ts
|
|
383
|
-
import { Observe } from "wenay-common2"
|
|
384
|
-
import { useStoreMirror, useStoreNode, useStoreKeys, useStoreSelect, useStoreChangedPaths, useListenEffect, useListenValue } from "wenay-react2"
|
|
385
|
-
```
|
|
386
|
-
|
|
387
|
-
Node subscription:
|
|
388
|
-
```
|
|
389
|
-
const price = useStoreNode(store.node.data.BTC)
|
|
390
|
-
price.value
|
|
391
|
-
price.exists
|
|
392
|
-
price.replace(123)
|
|
393
|
-
price.refresh()
|
|
394
|
-
```
|
|
395
|
-
|
|
396
|
-
Selection subscription:
|
|
397
|
-
```
|
|
398
|
-
const sel = useStoreSelect(store.update({data: {BTC: true}, meta: {status: true}}))
|
|
399
|
-
sel.value
|
|
400
|
-
sel.get()
|
|
401
|
-
```
|
|
402
|
-
|
|
403
|
-
Network mirror is explicit:
|
|
404
|
-
```
|
|
405
|
-
const mirror = useStoreMirror(remoteStore, {data: {}, meta: {}}, {
|
|
406
|
-
mask: {data: {BTC: true}, meta: {status: true}},
|
|
407
|
-
current: true,
|
|
408
|
-
drain: 250,
|
|
409
|
-
})
|
|
410
|
-
|
|
411
|
-
mirror.value
|
|
412
|
-
mirror.ready
|
|
413
|
-
mirror.sync()
|
|
414
|
-
mirror.stop()
|
|
415
|
-
```
|
|
416
|
-
|
|
417
|
-
Per-key feed (`store.each()` of common2 — one cb per CHANGED top-level key):
|
|
418
|
-
```
|
|
419
|
-
useStoreEach(store, (key, value, ctx) => {
|
|
420
|
-
value === undefined ? removeRow(key) : upsertRow(key, value)
|
|
421
|
-
}, {enabled?})
|
|
422
|
-
```
|
|
423
|
-
`undefined` = key deleted; a root replace (store.replace / mirror keyframe) expands into one call per key. The fold target should live outside React state (ref/Map/grid api) — the hook renders nothing itself. The wire counterpart is `useStoreReplayEach` below.
|
|
424
|
-
|
|
425
|
-
Listen helpers:
|
|
426
|
-
```
|
|
427
|
-
useListenEffect(listen, (...args) => {})
|
|
428
|
-
const value = useListenValue(listen, {initial})
|
|
429
|
-
const args = useListenArgs(listen)
|
|
430
|
-
```
|
|
431
|
-
|
|
432
|
-
The hook does not choose transport. `remoteStore` needs `{ get(mask?), changed }` and may also provide `changedPaths`; apps may implement it with RPC, WebSocket, SSE, or test HTTP. `changedPaths` is used as a transport optimization: mirror pulls `mask ∩ paths` instead of the whole mask. `initial` is only the local mirror seed; changing it does not reset an existing mirror. `mask` is treated as a small declarative StoreMask, so structurally equal inline masks do not resubscribe. For add/delete keys, subscribe to the parent object node with `useStoreKeys(node)`; this also covers deep objects. If a state key conflicts with StoreNode methods such as `count`, use `store.node.at("count")`.
|
|
433
|
-
|
|
434
|
-
## Replay React Adapter
|
|
435
|
-
Client-side hooks over the wenay-common2 Replay stack (snapshot + sequenced delta line). Server parts (`conflateReplay`, `archiveReplay`, `createRpcServerAuto` replayOpts) are per-connection and stay hook-free by design.
|
|
436
|
-
|
|
437
|
-
```ts
|
|
438
|
-
import { useReplaySubscribe, useReplayRouteSubscribe, useStoreReplaySync, useStoreReplayMirror, useStoreReplayRouteSync, useStoreReplayRouteMirror, useStoreReplayEach, useReplayFrame, useReplayHistory } from "wenay-react2"
|
|
439
|
-
|
|
440
|
-
// any replay line ({line, since, keyframe, frame?, frameLine?} remote)
|
|
441
|
-
const sub = useReplaySubscribe(remote, (frame) => draw(frame), {onSeq?, onError?, since?, enabled?, staleMs?, onStale?, policy?, hint?})
|
|
442
|
-
sub.ready; sub.error; sub.seq(); sub.restart()
|
|
443
|
-
sub.stale; sub.lastTs() // freshness (needs staleMs): stale re-renders on fresh<->stale transitions ONLY; lastTs() is a getter like seq()
|
|
444
|
-
// policy: 'queue' (default, nothing skipped) | 'frame' (rides the server frameLine when present: on lag the
|
|
445
|
-
// server drops and recovers with a mini-frame; old servers/in-proc degrade to 'queue'). hint -> the line's frame condenser.
|
|
446
|
-
|
|
447
|
-
// explicit route hand-off over the same logical replay line (relay <-> direct, etc.)
|
|
448
|
-
const routed = useReplayRouteSubscribe(relayRemote, (frame) => draw(frame), {label: "relay", onRoute?})
|
|
449
|
-
await routed.switchRoute(directRemote, {label: "direct"})
|
|
450
|
-
routed.ready; routed.switching; routed.route; routed.seq(); routed.label(); routed.active()
|
|
451
|
-
// route hooks do not expose stale/lastTs yet; use non-route hooks when freshness is required.
|
|
452
|
-
// store patch line (Observe.exposeStoreReplay(...).api.replay)
|
|
453
|
-
const mirror = useStoreReplayMirror(remote, initial, {enabled?}) // creates the mirror store; same staleMs/stale/policy surface
|
|
454
|
-
mirror.store; mirror.ready; mirror.seq(); mirror.restart()
|
|
455
|
-
const sync = useStoreReplaySync(existingStore, remote) // same, store supplied by the app
|
|
456
|
-
const routedSync = useStoreReplayRouteSync(existingStore, relayRemote, {label: "relay"}) // same store, switchRoute() replaces the route after catch-up
|
|
457
|
-
const routedMirror = useStoreReplayRouteMirror(relayRemote, initial, {label: "relay"}) // mirror store + route hand-off controller
|
|
458
|
-
|
|
459
|
-
// per-key fold over the same line (Observe.syncStoreReplayEach counterpart): cb per CHANGED top-level key,
|
|
460
|
-
// first delivery = keyframe expanded per key, (key, undefined) = deleted — grid rows without special cases
|
|
461
|
-
const feed = useStoreReplayEach<Rows>(remote, (key, row) => row === undefined ? removeRow(key) : upsertRow(key, row), {drain?, initial?})
|
|
462
|
-
feed.store; feed.ready; feed.stale; feed.seq(); feed.restart() // same controller surface as the mirror
|
|
463
|
-
// the mirror store lives in a ref: in-mount resubscribes (StrictMode/restart/enabled) reconnect by tail ON TOP of
|
|
464
|
-
// kept state — the consumer sees only the diff. After a full unmount: {since: prev.seq(), initial: prev.store.snapshot()}
|
|
465
|
-
|
|
466
|
-
// pull at YOUR pace (frame model): timer around remote.frame(seq, hint) — server condenses, no backlog, no live socket sub
|
|
467
|
-
const pf = useReplayFrame(remote, (quote) => apply(quote), {intervalMs: 500, hint?})
|
|
468
|
-
pf.ready; pf.error; pf.seq(); pf.pull(); pf.restart() // error (e.g. sacred line evicted) STOPS pulling until restart()
|
|
469
|
-
|
|
470
|
-
// time machine over Replay.openHistory(storage, live?)
|
|
471
|
-
const tt = useReplayHistory(history, (frame) => draw(frame), {head: () => replay.head()})
|
|
472
|
-
tt.live; tt.seq; tt.head; tt.pause(); tt.play(); tt.seek({seq})
|
|
473
|
-
```
|
|
474
|
-
|
|
475
|
-
Route hand-off here is the MANUAL surface (`switchRoute`). common2 1.0.67 `Replay.createRouteCoordinator` moves route decisions (policy, promote/fallback/shadow) out of the consumer; a coordinator `link.subscribe(cb)` handle has the same shape (`ready, seq(), label(), active()`) and survives every route change, so components consuming a coordinator link do not need `switchRoute` at all. common2 1.0.68 supplies the direct transport for it: `createWebRtcConnector` with an app-injected `rtc: () => new RTCPeerConnection(cfg)` factory (the browser — i.e. the React app — owns that injection) and signaling over the existing RPC socket (`createSignalHub`). common2 1.0.69 wraps the whole stack into the `Peer` SDK (`wenay-common2/peer`): `createPeerClient(...).peer(account)` returns a live mirrored store (works with `useStoreNode`/`useStoreKeys` as-is) + route control, surviving relay<->direct hand-offs in one seq space. `usePeer(client, account)` is the thin React adapter: it returns that mirror plus low-frequency route/status and explicit route/resync controls; it does not own journal, repair or transport state. `usePeerCalls(manager)` binds a `Peer.createCallManager` to rings/active/call UI state without owning `manager.close()` or signal policy. The exact interactive components used by QA cards 41–44 ship from `wenay-react2/demo/peer-media`; [`doc/examples/peer-call-media.tsx`](examples/peer-call-media.tsx) shows the import. `usePeerPresence(fragment.presence)` subscribes before reading the host snapshot and exposes online/offline edges. For calls with media, wire `Peer.createMediaRelay` on the server (`publishOf(owner)`, `watchOf(watcher)`, `canWatch`) and attach viewers only when the server-owned call policy grants access; React must never make the ACL decision.
|
|
476
|
-
|
|
477
|
-
Media lines (common2 1.0.66 `Media.createAudioSource` / `Media.createVideoSource`) are ordinary binary Listen sources; with `replay:true` their `listen` is a replay line, so `useReplaySubscribe` / `useReplayFrame` consume mic/camera frames with no media-specific hook. `useMediaSource(kind, options)` is only the capture lifecycle adapter (`start`, `stop`, device selection, state and stats); it stops a started source on unmount and returns `listen` unchanged. Each frame is one `Uint8Array` (`Media.decodeMediaFrame`); draw/play it via ref (canvas, AudioContext), never useState — the same rule as any high-frequency line. Without `replay`, the plain `listen` works with the listen hooks above.
|
|
478
|
-
|
|
479
|
-
Contract: `off()` on unmount, StrictMode-safe; seq survives resubscribes inside one mount (keepSeq, default on) — a resubscribe reconnects with `{since}` and gets the journal tail, not a keyframe. Across a FULL unmount/remount keep the position outside via `onSeq` and pass it back as `since`. `seq()` is a getter — high-frequency lines (video frames, ticks) do not re-render per event; draw to canvas via ref, bypassing VDOM. Freshness: detection lives in wenay-common2 (`staleMs` watchdog); the non-route hooks mirror its edge-triggered `onStale` into `stale`, so a fresh 100 ev/s line causes zero extra renders. Route hand-off is explicit through `switchRoute(nextRemote, {label?, since?, reset?, policy?, hint?})`: old route stays live while the replacement catches up by `seq`, then closes. `useReplayHistory` is archive playback — staleness does not apply. QA cards 23 (video line + conflation + time travel + freshness), 24 (store sync), 25 (per-key feed), and 26 (route hand-off) are the live examples.
|
|
480
|
-
|
|
481
|
-
## Logs
|
|
482
|
-
```
|
|
483
|
-
logsApi.addLogs({id: "api", time: new Date(), txt: "done", var: 1})
|
|
484
|
-
<logsApi.React.Message />
|
|
485
|
-
<logsApi.React.PageLogs />
|
|
486
|
-
<logsApi.React.Setting />
|
|
487
|
-
|
|
488
|
-
const customLogs = getLogsApi<MyFields>({limitPer: 500, limit?: 50, varMin?: 0, settingsKey?: "myLogs"})
|
|
489
|
-
// each getLogsApi call now owns ISOLATED full/mini/settings state (settingsKey persists the
|
|
490
|
-
// instance settings; omitted = fresh non-persisted). The global logsApi keeps the legacy shared state.
|
|
491
|
-
const headless = createLogsController<MyFields>({options: {limitPer: 500, limit: 50}})
|
|
492
|
-
|
|
493
|
-
const messageNotifications = useMessageEventLogsController({maxVisible: 4})
|
|
494
|
-
<MessageEventLogsView controller={messageNotifications} zIndex={80} />
|
|
495
|
-
|
|
496
|
-
const contextTable = useLogsTableController()
|
|
497
|
-
const contextNotifications = useLogsNotificationsController()
|
|
498
|
-
|
|
499
|
-
const mini = useMiniLogsTable({data: rows, onClick: e => console.log(e.data)})
|
|
500
|
-
<AgGridTable {...mini.props} />
|
|
501
|
-
<MiniLogsTable data={rows} />
|
|
502
|
-
|
|
503
|
-
const table = useLogsPageTable() // full-page logs grid controller
|
|
504
|
-
<AgGridTable {...table.gridProps} />
|
|
505
|
-
table.fit(); table.applyImportanceFilter(min?); table.appendRow(row); table.getApi()
|
|
506
|
-
```
|
|
507
|
-
|
|
508
|
-
Logger notification/tabs chrome uses `--logs-*` CSS variables; apps can theme it without forking logger components. `createLogsController` owns the headless append/limit/settings state for custom integrations. `useMessageEventLogsController` owns the global notification queue/timers/settings, `MessageEventLogsView` draws it, and `MessageEventLogs` / `logsApi.React.Message` remain compatibility wrappers. `MiniLogs` remains the compatibility wrapper; new compact-table code can use `useMiniLogsTable` or `MiniLogsTable`. `useLogsPageTable` owns the full-page table (mount-time row snapshot, transaction appends from the mini feed, one importance-filter method); `PageLogs` / `logsApi.React.PageLogs` remain the thin compatibility wrappers over it.
|
|
509
|
-
|
|
510
|
-
## Styles / Theme
|
|
511
|
-
```
|
|
512
|
-
tokens.color.bgDark
|
|
513
|
-
tokens.menu.outlineColor
|
|
514
|
-
tokens.logs.notificationAccent
|
|
515
|
-
tokens.zIndex.modal
|
|
516
|
-
|
|
517
|
-
GridStyleDefault() // inject legacy grid CSS vars/classes
|
|
518
|
-
StyleGridDefault // common ag-grid style object
|
|
519
|
-
buildAgTheme("dark" | "light")
|
|
520
|
-
useAgGridTheme("dark" | "light")
|
|
521
|
-
colDefCentered / colDefWrap / numericComparator
|
|
522
|
-
```
|
|
523
|
-
|
|
524
|
-
Shared CSS variables include `--menu-outline-color`, `--logs-*`, `--dlg-*`, `--wnd-*`, `--tb-*`, `--cols-grid-*`, `--cols-menu-*`, `--cols-dots-*`, and `--cols-card-*`.
|
|
525
|
-
|
|
526
|
-
## Charts
|
|
527
|
-
```
|
|
528
|
-
<MyChartEngine style={{height: 400}} />
|
|
529
|
-
createChartCanvas(config) -> canvas controller
|
|
530
|
-
```
|
|
531
|
-
|
|
532
|
-
The chart engine surface is still mostly low-level. Keep product-specific chart wrappers in the app.
|
|
533
|
-
|
|
534
|
-
## QA Stand
|
|
535
|
-
```
|
|
536
|
-
npm run testReact // http://localhost:3010/
|
|
537
|
-
```
|
|
538
|
-
|
|
539
|
-
The stand lives in `src/common/testUseReact/qa.tsx`. Use it for visual checks; agGrid4 overlay/dynamic-column demos are dedicated QA cards.
|
|
540
|
-
Two tabs: Active checks (current work) and Verified archive (`#archive`) - verified/fixed cards move
|
|
541
|
-
there and stay runnable for regression re-checks.
|
|
1
|
+
# wenay-react2 - BRIEF cheat sheet (canonical UI/controller API)
|
|
2
|
+
|
|
3
|
+
> Root import: `import { ... } from "wenay-react2"`.
|
|
4
|
+
> Notation: `name(args) -> ret`. JSX examples show the intended public path, not every prop.
|
|
5
|
+
> Short controller-style names are canonical. Removed names are recorded in
|
|
6
|
+
> **WENAY_REACT2_RENAMES.md** for migration only; old aliases are not exported.
|
|
7
|
+
|
|
8
|
+
## Standard
|
|
9
|
+
```
|
|
10
|
+
Hooks that own lifecycle/state return a small controller:
|
|
11
|
+
api.props / api.bind // props to spread on an element
|
|
12
|
+
api.open() / close() / set() // modal/menu-like state
|
|
13
|
+
api.update() / remove() / clean() // data buffers
|
|
14
|
+
api.fit() / flush() / sync() // ag-grid lifecycle
|
|
15
|
+
|
|
16
|
+
Components receive controllers when state must outlive the component.
|
|
17
|
+
Library primitives never know app-specific terms, domain objects, or group policy.
|
|
18
|
+
Put app-specific layout/build rules in an app wrapper above the primitive.
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Render Memory
|
|
22
|
+
```
|
|
23
|
+
updateBy(obj) / useUpdateBy(obj) // subscribe current component to renderBy(obj)
|
|
24
|
+
updateBy(obj, cb) // imperative callback instead of a re-render;
|
|
25
|
+
// cb goes through a ref - inline identity is fine
|
|
26
|
+
renderBy(obj, ms?) // emit render for subscribers
|
|
27
|
+
renderByRevers(obj, ms?, reverse?=true) // reverse/last affect ONLY React subscribers;
|
|
28
|
+
renderByLast(obj, ms?) // updateBy(obj, cb) callbacks always all run first
|
|
29
|
+
|
|
30
|
+
const state = { count: 0 }
|
|
31
|
+
const api = createUpdateApi(state)
|
|
32
|
+
api.use() // hook subscription
|
|
33
|
+
api.emit(ms?) // renderBy(state)
|
|
34
|
+
api.on(obj => {}) -> off
|
|
35
|
+
|
|
36
|
+
const api2 = useUpdateByApi(state) // hook + controller in one call
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Persistent process memory:
|
|
40
|
+
```
|
|
41
|
+
memoryGetOrCreate(key, def, {abs?, deepAutoMerge?, reversDeep?}) -> def-or-stored
|
|
42
|
+
memoryGetById(key, def, id) -> stored only while id is the same
|
|
43
|
+
memorySet(key, data)
|
|
44
|
+
memoryGet(key)
|
|
45
|
+
memoryUpdate(key, mutate) -> cur? // mutate + rerender + announce in one call
|
|
46
|
+
memoryMarkDirty(key) // announce an in-place mutation of a memoryGetOrCreate object
|
|
47
|
+
createSearchHistory({key, max?}) // reusable small persisted search history controller
|
|
48
|
+
memoryMaps // rnd / resize / other maps
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Persistence contract (memoryCache): the library NEVER writes storage by itself. The persisted maps
|
|
52
|
+
(floatingWindowMap, mapResiReact, mapRightMenu, memoryProps) are `ObservableMap`s - set/delete/clear
|
|
53
|
+
announce themselves, in-place mutations are announced at the commit points (drag/resize stop,
|
|
54
|
+
menu drag end, setPlace). memoryCache observes the maps it owns; the app owns the write policy:
|
|
55
|
+
```
|
|
56
|
+
memoryCache.load() // once on start; remembers the saved snapshot
|
|
57
|
+
memoryCache.onDirty((scope?, key?) => ...) -> off // dirty channel (coalesced, async)
|
|
58
|
+
memoryCache.saveDebounced(ms?) / save() / flush() // write only payloads that differ from the snapshot
|
|
59
|
+
memoryCache.isDirty() // cheap hint, e.g. a beforeunload guard
|
|
60
|
+
|
|
61
|
+
memoryCache.load()
|
|
62
|
+
const off = memoryCache.onDirty(() => memoryCache.saveDebounced(800))
|
|
63
|
+
document.addEventListener("visibilitychange",
|
|
64
|
+
() => { if (document.visibilityState == "hidden") void memoryCache.flush() })
|
|
65
|
+
window.addEventListener("pagehide", () => { void memoryCache.flush() }) // backstop: flush is async
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The load + dirty->saveDebounced part of that contract as one hook (React components):
|
|
69
|
+
```
|
|
70
|
+
const persistence = useCacheMapPersistence(memoryCache, delayMs?=300)
|
|
71
|
+
persistence.isDirty(); persistence.flush(); persistence.save(); persistence.reload() // reload = load() alias
|
|
72
|
+
```
|
|
73
|
+
The pagehide/visibility flush backstops stay app-side (see above) - the hook deliberately does not own them.
|
|
74
|
+
|
|
75
|
+
## Outside Click / Buttons
|
|
76
|
+
```
|
|
77
|
+
const outside = useOutside({onOutside, enabled?})
|
|
78
|
+
<div {...outside.props} />
|
|
79
|
+
outside.enable()
|
|
80
|
+
outside.disable()
|
|
81
|
+
outside.contains(event.target)
|
|
82
|
+
|
|
83
|
+
<OutsideClickArea outsideClick={close} status={open}>...</OutsideClickArea>
|
|
84
|
+
|
|
85
|
+
<Button button={<button>Open</button>} outClick keyForSave?>{...}</Button> // keySave = deprecated alias
|
|
86
|
+
<OutsideButton button={...}>{...}</OutsideButton>
|
|
87
|
+
<HoverButton button={...}>{...}</HoverButton>
|
|
88
|
+
<AbsoluteButton button={...}>{...}</AbsoluteButton>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Element Size / Resize Observer
|
|
92
|
+
```
|
|
93
|
+
const box = useResizeObserver<HTMLDivElement>(() => onResize()) // shared singleton observer
|
|
94
|
+
<div ref={box.ref} />; box.element()
|
|
95
|
+
|
|
96
|
+
const size = useElementSize<HTMLDivElement>() // "want the size -> get the value/method"
|
|
97
|
+
<div ref={size.ref} />
|
|
98
|
+
size.width; size.height // state, rounded, no-op resize does not re-render
|
|
99
|
+
size.getSize() // live getter (exact, no render wait)
|
|
100
|
+
|
|
101
|
+
setResizeableElement(el) / removeResizeableElement(el) // legacy imperative auto-shrink path, unchanged
|
|
102
|
+
```
|
|
103
|
+
Both hooks ride the same module-level `CResizeObserver` singleton (one native `ResizeObserver` for the whole app). QA card 19.
|
|
104
|
+
|
|
105
|
+
## Drag / Floating Windows
|
|
106
|
+
```
|
|
107
|
+
const drag = useDraggableApi({initialPosition, holdMs?, onDragStart?, onDragEnd?, onMove?, trackState?})
|
|
108
|
+
<div {...drag.bind} style={{transform: `translate(${drag.position.x}px, ${drag.position.y}px)`}} />
|
|
109
|
+
drag.setPosition({x, y})
|
|
110
|
+
drag.resetPosition()
|
|
111
|
+
drag.cancelDrag()
|
|
112
|
+
// imperative path: onMove(p) fires per move tick (through a ref); trackState:false stops
|
|
113
|
+
// per-tick re-renders - position lives only in drag.positionRef/onMove (DragBox is this shape)
|
|
114
|
+
|
|
115
|
+
const r = useReorder({order, commit, move?, canDrag?, preview?, holdMs?}) // mini reorder-by-drag
|
|
116
|
+
<div ref={r.listRef}>{order.map(k => { // children 1:1 with order
|
|
117
|
+
const it = r.item(k) // it.dragging / it.active
|
|
118
|
+
return <div key={k} {...it.props} style={{...own, ...it.style}} /> // ONE commit(next) on drop
|
|
119
|
+
})}</div>
|
|
120
|
+
|
|
121
|
+
const b = useReorderBoard({columns: [{key, items}], commit, // drag between vertical columns
|
|
122
|
+
canDrag?, holdMs?, onDragStart?, onDragMove?, onOverChange?, onDragEnd?})
|
|
123
|
+
<div ref={b.columnRef('todo')}>{items.map(k => ...b.item(k)...)}</div> // one div per column, any count
|
|
124
|
+
b.over // {col, index} | null - live target
|
|
125
|
+
// column gravity is YOUR CSS: justify-content flex-start packs up, flex-end packs down
|
|
126
|
+
|
|
127
|
+
<FloatingWindow keyForSave="tool" size={{width: 320, height: 240}} header={<div>Tool</div>}
|
|
128
|
+
onClickClose={() => setOpen(false)}> {/* onCLickClose (typo) = deprecated alias */}
|
|
129
|
+
<Panel />
|
|
130
|
+
</FloatingWindow>
|
|
131
|
+
|
|
132
|
+
const wnd = useFloatingWindowController({keyForSave: "custom-tool", size: {width: 320, height: 240}})
|
|
133
|
+
wnd.position // {x,y}; bind wnd.onHeaderMouseDown/onHeaderTouchStart for custom chrome
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Modal / Input
|
|
137
|
+
```
|
|
138
|
+
<ModalProvider>
|
|
139
|
+
<App />
|
|
140
|
+
</ModalProvider>
|
|
141
|
+
|
|
142
|
+
const modal = useModal()
|
|
143
|
+
modal.open(<Dialog />)
|
|
144
|
+
modal.set(<Dialog />)
|
|
145
|
+
modal.close()
|
|
146
|
+
modal(null) // callable shortcut for clearing
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Input helpers:
|
|
150
|
+
```
|
|
151
|
+
const text = useTextInputPanel({callback: txt => {}, txt: ""}) // headless value + submit API
|
|
152
|
+
const file = useFileInputPanel({callback: file => {}}) // headless file + submit API
|
|
153
|
+
<TextInputPanel callback={txt => {}} name="Name" txt="" /> // thin visual wrapper over hook
|
|
154
|
+
<FileInputPanel callback={file => {}} name="File" /> // thin visual wrapper over hook
|
|
155
|
+
<TextInputModal callback={...} outClick={modal.close} />
|
|
156
|
+
<FileInputModal callback={...} outClick={modal.close} />
|
|
157
|
+
<FreeModal outClick={modal.close} size={{width: 400, height: 260}}>...</FreeModal>
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Settings Dialog
|
|
161
|
+
```
|
|
162
|
+
<SettingsDialog trigger={<span>settings</span>} sections={[{key, name, render, children?, parentKey?, searchText?, keywords?}]} defaultSection? /> // searchable tree + persisted search history; history closes when search focus leaves
|
|
163
|
+
const settings = useSettingsDialogController({sections, defaultSection?}) // open/search/tree/history/resize actions for custom chrome
|
|
164
|
+
registerSettingsSection({key, name, render, parentKey?, searchText?, keywords?}) -> unregister
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## UI Slot
|
|
168
|
+
```
|
|
169
|
+
const slot = createUiSlot({key, places: {top: "Top bar", side: "Sidebar"}, def: "top"})
|
|
170
|
+
|
|
171
|
+
<slot.Slot place="top">{content}</slot.Slot> // each mount point decides by itself
|
|
172
|
+
<slot.PlacementSetting /> // segmented place switcher; setPlace marks memoryCache dirty
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Toolbar
|
|
176
|
+
```
|
|
177
|
+
const tb = createToolbar({key, items: [{key, title, icon?, short?, render?, onClick?, defaultVisible?, fixed?}], def?, settingsItem?, resetItem?, source?, sourceMode?})
|
|
178
|
+
|
|
179
|
+
<tb.Bar settings reset? /> // the live bar; settings adds a gear; reset is hidden by default; item moves animate
|
|
180
|
+
<tb.Settings /> // the PURE config editor - same element drops into a settings section
|
|
181
|
+
tb.api.useConfig() / getConfig() / setConfig(next) / setOrder(order) / show(key,on) / setDensity(key) / reset()
|
|
182
|
+
tb.api.showSettings(on) / showReset(on) // pseudo-controls visibility
|
|
183
|
+
tb.api.useItems() // headless bar: ordered visible [{item, density, content}] - custom markup
|
|
184
|
+
tb.api.onChange.on(cfg => ...) -> off // fires on every edit
|
|
185
|
+
tb.api.dispose() // release the external-source subscription (HMR/remounts); persisted config stays
|
|
186
|
+
|
|
187
|
+
registerToolbarDensity({key, name, renderItem?}) -> unregister // built-ins: 'icon', 'label'
|
|
188
|
+
toolbarItemIcon(item) // the item's icon, or its first letters as a text pseudo-icon
|
|
189
|
+
```
|
|
190
|
+
Config `{order, visible, density}` is serializable and persisted like createUiSlot
|
|
191
|
+
(memoryGetOrCreate -> memoryCache, the app owns the write policy). Items added in an app update are merged
|
|
192
|
+
into a stale persisted config (appended, default-visible), removed ones are ignored. `icon` is
|
|
193
|
+
optional: in icon density an icon-less item renders the first letters of its short/title.
|
|
194
|
+
The gear and reset button are pseudo-items: `settingsItem: {title?, icon?}` and `resetItem` re-skin them,
|
|
195
|
+
and the editor has separated toggle rows for them (`visible['__settings']` / `visible['__reset']`, no order slots - they always sit at the bar edge). Settings is visible by default; reset is hidden by default unless `resetItem.defaultVisible: true` or `api.showReset(true)` enables it. `resetItem: false` removes the reset feature.
|
|
196
|
+
|
|
197
|
+
`source?` makes the toolbar a VIEW over an external `UiListSource`. Default `sourceMode:"orderVisible"`
|
|
198
|
+
means the source owns item order and item visibility, so one config can drive a grid, its icon menu
|
|
199
|
+
AND the toolbar together (`columnState.api.listSource`, QA card 31). `sourceMode:"order"` shares only
|
|
200
|
+
the source-key order; item membership, density, pseudo-controls, and extra non-source item positions
|
|
201
|
+
stay in the toolbar store (QA card 30). Without `source` the toolbar keeps its own store (standalone mode).
|
|
202
|
+
|
|
203
|
+
## Callback Hub
|
|
204
|
+
```
|
|
205
|
+
const hub = createCallbackHub<[Args]>(emit => api.onX(emit)) // one slot -> many subscribers
|
|
206
|
+
hub.on(cb) -> off
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Params
|
|
210
|
+
```
|
|
211
|
+
<ParamsEditor
|
|
212
|
+
params={params}
|
|
213
|
+
onChange={next => setParams(next)}
|
|
214
|
+
onExpand={next => setParams(next)}
|
|
215
|
+
expandStatus?
|
|
216
|
+
expandStatusLvl?
|
|
217
|
+
/>
|
|
218
|
+
|
|
219
|
+
const controller = useParamsEditorController({params, onChange, onExpand?})
|
|
220
|
+
|
|
221
|
+
<ParamsEdit params={params} onSave={next => save(next)} />
|
|
222
|
+
<ParamRow name="Name">...</ParamRow>
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Menu
|
|
226
|
+
```
|
|
227
|
+
type MenuItem = { name, actionKey?, onClick?, next?, func?, status? } | false | null | undefined
|
|
228
|
+
|
|
229
|
+
<Menu data={items} coordinate={{x: 0, y: 0}} />
|
|
230
|
+
|
|
231
|
+
<contextMenu.Layer>{children}</contextMenu.Layer>
|
|
232
|
+
contextMenu.openAt(event, [{name: "Copy", actionKey: "grid.copy", onClick: copy}], {source: "grid"})
|
|
233
|
+
contextMenu.stats.getSnapshot() // local counters: opens, source/layer usage, actionTotals, keyed actions
|
|
234
|
+
contextMenu.close()
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Use `contextMenu.openAt(e, items, {source?})` for new right-click integrations. `contextMenu.map` still exists as a legacy Layer queue, but it is not the recommended path for new code. `contextMenu.stats` is an in-memory diagnostics API; it never persists data, never reports over the network, and records per-action counters only for explicit `actionKey` values. Unkeyed items are counted in aggregate totals without storing labels.
|
|
238
|
+
|
|
239
|
+
`Menu` keeps the hovered/open item in internal React state. `status` is only an initial open hint and a compatibility field passed to custom renderers; menu objects are not mutated.
|
|
240
|
+
|
|
241
|
+
Floating right menu:
|
|
242
|
+
```
|
|
243
|
+
<DropdownMenu elements={items} trigger={iconOrRender} classNames={...} styles={...} />
|
|
244
|
+
const menu = useRightMenuController({elements, keyForSave?})
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`DropdownMenu` is a floating action menu with caller-owned trigger/content styling, not the main context-menu primitive. `useRightMenuController` exposes the same open/fixed/select/submenu/drag state for custom views; `DropdownMenu` remains the compatible visual wrapper.
|
|
248
|
+
|
|
249
|
+
## agGrid4
|
|
250
|
+
Plain declarative rows:
|
|
251
|
+
```
|
|
252
|
+
<AgGridTable<Row> rowData={rows} columnDefs={cols} />
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Buffered mirror table:
|
|
256
|
+
```
|
|
257
|
+
const grid = useAgGrid<Row>({getId: row => row.id})
|
|
258
|
+
<AgGridTable<Row> controller={grid} columnDefs={cols} />
|
|
259
|
+
|
|
260
|
+
grid.update({newData: rows})
|
|
261
|
+
grid.remove([{id}])
|
|
262
|
+
grid.clean()
|
|
263
|
+
grid.fit()
|
|
264
|
+
grid.flush()
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Shared module store:
|
|
268
|
+
```
|
|
269
|
+
export const mainTable = createGridBuffer<Row>({getId, externalBuffer})
|
|
270
|
+
|
|
271
|
+
const grid = useAgGrid({core: mainTable})
|
|
272
|
+
<AgGridTable<Row> controller={grid} columnDefs={cols} />
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Overlay patches over React-owned `rowData`:
|
|
276
|
+
```
|
|
277
|
+
const core = createGridBuffer<Row>({getId, mode: "overlay", pushDefaults: {add: false}})
|
|
278
|
+
const grid = useAgGrid({core})
|
|
279
|
+
|
|
280
|
+
<AgGridTable<Row> controller={grid} rowData={rows} columnDefs={cols} getRowId={p => getId(p.data)} />
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Dynamic columns are pure names + lifecycle replay:
|
|
284
|
+
```
|
|
285
|
+
const columns = createColumnBuffer<Row>()
|
|
286
|
+
|
|
287
|
+
columns.api.setNames(["a", "b"])
|
|
288
|
+
columns.control.attach(api, {
|
|
289
|
+
apply: ({api, names}) => api.setGridOption("columnDefs", buildColumnDefs(names)),
|
|
290
|
+
})
|
|
291
|
+
columns.api.apply()
|
|
292
|
+
columns.control.detach()
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`createColumnBuffer()` knows nothing about groups, `columnDefs`, business names, or where columns live.
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
## columnState
|
|
299
|
+
A persisted column layer over a keyed column set - order / visibility / width / sort / filter in a
|
|
300
|
+
standalone config store, with an OPTIONAL two-way ag-grid adapter. Mobile card views consume the
|
|
301
|
+
SAME config with no ag-grid at all. agGrid4 wrappers are never modified: this is the app-level
|
|
302
|
+
column wrapper `WRAPPER.md` postulates, packaged as a primitive.
|
|
303
|
+
```
|
|
304
|
+
const cs = createColumnState({key, columns: [{key, title, short?, icon?, group?, fixed?, defaultVisible?, cardRole?}], def?, saveMs?})
|
|
305
|
+
|
|
306
|
+
cs.columns // the descriptors (UI renders from these + config)
|
|
307
|
+
cs.api.useConfig() / getConfig() / setConfig(next) / reset()
|
|
308
|
+
cs.api.show(key, on) / move(order) / setSort({key, dir} | null) / toggleSort(key) // asc->desc->off
|
|
309
|
+
cs.api.visibleKeys() // keys to render, in order (group-gated)
|
|
310
|
+
cs.api.onChange.on(cfg => ...) -> off
|
|
311
|
+
cs.api.usePresent() / isPresent(key) / setPresent(keys | null) // live-grid presence
|
|
312
|
+
cs.api.getPresentGate() / setPresentGate(keys | null) // app runtime availability gate, not persisted
|
|
313
|
+
cs.api.listSource // {order, visible} slice as a Toolbar `source`
|
|
314
|
+
```
|
|
315
|
+
Config `{v, order, visible, width, sort, filter, groups}` is serializable and persisted like
|
|
316
|
+
createToolbar (memoryGetOrCreate(key) -> memoryCache, the app owns the write policy); a stale config migrates
|
|
317
|
+
softly (unknown keys dropped, new columns appended default-visible, `fixed` pinned). `sort` is
|
|
318
|
+
STICKY: independent of visibility/selection, may point at a hidden or grid-absent column, changes
|
|
319
|
+
only by an explicit toggle/header click.
|
|
320
|
+
|
|
321
|
+
Two-way ag-grid adapter (opt-in, agGrid4 untouched):
|
|
322
|
+
```
|
|
323
|
+
<AgGridTable columnDefs={cols} autoSizeColumns={false}
|
|
324
|
+
onGridReady={e => cs.grid.attach(e.api)} // restore saved layout, then watch the grid
|
|
325
|
+
onGridPreDestroyed={() => cs.grid.detach()} /> // config survives remounts
|
|
326
|
+
```
|
|
327
|
+
Grid drag/resize/hide/sort/filter fold back into the config; UI edits apply to the grid; a column
|
|
328
|
+
removed from the live grid (dynamic defs) keeps its config entry and reads `isPresent==false`.
|
|
329
|
+
|
|
330
|
+
Icon menu - a 1:1 grid mirror (reorder in the grid <-> reorder the buttons):
|
|
331
|
+
```
|
|
332
|
+
<ColumnsMenu state={cs} compact? marks? tail? onItem? onTail? reorder? /> // default click = toggle visibility
|
|
333
|
+
<MenuStrip items={[{key, title, state:'on'|'off'|'disabled', marks?, fixed?}]} onItem? onMove? tail? compact? />
|
|
334
|
+
```
|
|
335
|
+
`ColumnsMenu` binds `MenuStrip` - a presentation-only layer that reports clicks/drags but never
|
|
336
|
+
interprets them - to the config. `compact` = icon-only buttons; an item with no icon shows its
|
|
337
|
+
first letters. `tail` = trailing non-column buttons (a table-standards cycler etc.). For new
|
|
338
|
+
settings-integrated toolbar/grid surfaces prefer `createToolbar({source: cs.api.listSource})`;
|
|
339
|
+
keep `ColumnsMenu` for compact button strips or custom presentation-only menus.
|
|
340
|
+
|
|
341
|
+
Mobile (no ag-grid): dots selector + card rows over the same config:
|
|
342
|
+
```
|
|
343
|
+
<ColumnDots state={cs} max?=8 /> // track of marks; dots = shown columns; tap empty=add,
|
|
344
|
+
// drag=LIVE replace (every empty mark crossed swaps the shown
|
|
345
|
+
// column immediately + small label names it - column search
|
|
346
|
+
// by finger), swipe up=remove, tap dot=select, sort cycles
|
|
347
|
+
<CardList<Row> state={cs} data={rows} getId? renderValue? /> // visible cols -> card fields; cardRole:'title'/'accent'
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Default wrapper for repeated grid/menu/mobile use:
|
|
351
|
+
```
|
|
352
|
+
const cg = createColumnGrid<Row>({
|
|
353
|
+
key: "orders.columns",
|
|
354
|
+
columnDefs: cols, // ColumnMeta inferred from colId/field/headerName
|
|
355
|
+
data: rows, // optional default; View props can override
|
|
356
|
+
getId: r => r.id,
|
|
357
|
+
autoSizeOnColumnCountChange: true, // optional fit when visible column count changes
|
|
358
|
+
columns: [{key: "name", fixed: true, cardRole: "title"}], // optional overrides
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
<cg.View mode="table" /> // default controls="auto" -> dots overlay
|
|
362
|
+
<cg.View mode="cards" data={otherRows} />
|
|
363
|
+
<cg.Table rowData={rows} getRowId={p => p.data.id} />
|
|
364
|
+
<cg.Menu compact />
|
|
365
|
+
<cg.Dots /> // no wrapper max; defaults to all columns
|
|
366
|
+
<cg.Toolbar settings />
|
|
367
|
+
<cg.Settings />
|
|
368
|
+
```
|
|
369
|
+
`createColumnGrid` returns `{state, toolbar, api, grid, tableProps, Table, Menu, Dots, Cards, Toolbar, Settings, View, dispose}`; `dispose()` releases factory-lifetime subscriptions (config onChange, toolbar source, pending fit) without touching the persisted config. The columnState BARREL stays ag-grid-free; `createColumnGrid` ships from its own module (both are exported from the package root).
|
|
370
|
+
Its `Table`/`tableProps()` attach/detach the columnState grid adapter automatically and default
|
|
371
|
+
`autoSizeColumns=false` so restored widths are not overwritten. `autoSizeOnColumnCountChange` is
|
|
372
|
+
separate and only calls `sizeColumnsToFit()` when the visible column count changes. `useColumnGrid(opts)`
|
|
373
|
+
is the same controller captured once for component-local setups; keep module-level `createColumnGrid`
|
|
374
|
+
when the controller must survive route/component remounts.
|
|
375
|
+
|
|
376
|
+
QA cards 28 (grid layer + F5 restore), 29 (mobile dots + cards), 30 (toolbar icon menu + grouped sub-column mode button), 31 (Toolbar over the same config), 32 (createColumnGrid wrapper + dots driving table/cards).
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
## Observe React Adapter
|
|
380
|
+
`wenay-common2` owns the store/listen/RPC primitives. `wenay-react2` owns React lifecycle hooks around them.
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
import { Observe } from "wenay-common2"
|
|
384
|
+
import { useStoreMirror, useStoreNode, useStoreKeys, useStoreSelect, useStoreChangedPaths, useListenEffect, useListenValue } from "wenay-react2"
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Node subscription:
|
|
388
|
+
```
|
|
389
|
+
const price = useStoreNode(store.node.data.BTC)
|
|
390
|
+
price.value
|
|
391
|
+
price.exists
|
|
392
|
+
price.replace(123)
|
|
393
|
+
price.refresh()
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Selection subscription:
|
|
397
|
+
```
|
|
398
|
+
const sel = useStoreSelect(store.update({data: {BTC: true}, meta: {status: true}}))
|
|
399
|
+
sel.value
|
|
400
|
+
sel.get()
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Network mirror is explicit:
|
|
404
|
+
```
|
|
405
|
+
const mirror = useStoreMirror(remoteStore, {data: {}, meta: {}}, {
|
|
406
|
+
mask: {data: {BTC: true}, meta: {status: true}},
|
|
407
|
+
current: true,
|
|
408
|
+
drain: 250,
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
mirror.value
|
|
412
|
+
mirror.ready
|
|
413
|
+
mirror.sync()
|
|
414
|
+
mirror.stop()
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Per-key feed (`store.each()` of common2 — one cb per CHANGED top-level key):
|
|
418
|
+
```
|
|
419
|
+
useStoreEach(store, (key, value, ctx) => {
|
|
420
|
+
value === undefined ? removeRow(key) : upsertRow(key, value)
|
|
421
|
+
}, {enabled?})
|
|
422
|
+
```
|
|
423
|
+
`undefined` = key deleted; a root replace (store.replace / mirror keyframe) expands into one call per key. The fold target should live outside React state (ref/Map/grid api) — the hook renders nothing itself. The wire counterpart is `useStoreReplayEach` below.
|
|
424
|
+
|
|
425
|
+
Listen helpers:
|
|
426
|
+
```
|
|
427
|
+
useListenEffect(listen, (...args) => {})
|
|
428
|
+
const value = useListenValue(listen, {initial})
|
|
429
|
+
const args = useListenArgs(listen)
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
The hook does not choose transport. `remoteStore` needs `{ get(mask?), changed }` and may also provide `changedPaths`; apps may implement it with RPC, WebSocket, SSE, or test HTTP. `changedPaths` is used as a transport optimization: mirror pulls `mask ∩ paths` instead of the whole mask. `initial` is only the local mirror seed; changing it does not reset an existing mirror. `mask` is treated as a small declarative StoreMask, so structurally equal inline masks do not resubscribe. For add/delete keys, subscribe to the parent object node with `useStoreKeys(node)`; this also covers deep objects. If a state key conflicts with StoreNode methods such as `count`, use `store.node.at("count")`.
|
|
433
|
+
|
|
434
|
+
## Replay React Adapter
|
|
435
|
+
Client-side hooks over the wenay-common2 Replay stack (snapshot + sequenced delta line). Server parts (`conflateReplay`, `archiveReplay`, `createRpcServerAuto` replayOpts) are per-connection and stay hook-free by design.
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
import { useReplaySubscribe, useReplayRouteSubscribe, useStoreReplaySync, useStoreReplayMirror, useStoreReplayRouteSync, useStoreReplayRouteMirror, useStoreReplayEach, useReplayFrame, useReplayHistory } from "wenay-react2"
|
|
439
|
+
|
|
440
|
+
// any replay line ({line, since, keyframe, frame?, frameLine?} remote)
|
|
441
|
+
const sub = useReplaySubscribe(remote, (frame) => draw(frame), {onSeq?, onError?, since?, enabled?, staleMs?, onStale?, policy?, hint?})
|
|
442
|
+
sub.ready; sub.error; sub.seq(); sub.restart()
|
|
443
|
+
sub.stale; sub.lastTs() // freshness (needs staleMs): stale re-renders on fresh<->stale transitions ONLY; lastTs() is a getter like seq()
|
|
444
|
+
// policy: 'queue' (default, nothing skipped) | 'frame' (rides the server frameLine when present: on lag the
|
|
445
|
+
// server drops and recovers with a mini-frame; old servers/in-proc degrade to 'queue'). hint -> the line's frame condenser.
|
|
446
|
+
|
|
447
|
+
// explicit route hand-off over the same logical replay line (relay <-> direct, etc.)
|
|
448
|
+
const routed = useReplayRouteSubscribe(relayRemote, (frame) => draw(frame), {label: "relay", onRoute?})
|
|
449
|
+
await routed.switchRoute(directRemote, {label: "direct"})
|
|
450
|
+
routed.ready; routed.switching; routed.route; routed.seq(); routed.label(); routed.active()
|
|
451
|
+
// route hooks do not expose stale/lastTs yet; use non-route hooks when freshness is required.
|
|
452
|
+
// store patch line (Observe.exposeStoreReplay(...).api.replay)
|
|
453
|
+
const mirror = useStoreReplayMirror(remote, initial, {enabled?}) // creates the mirror store; same staleMs/stale/policy surface
|
|
454
|
+
mirror.store; mirror.ready; mirror.seq(); mirror.restart()
|
|
455
|
+
const sync = useStoreReplaySync(existingStore, remote) // same, store supplied by the app
|
|
456
|
+
const routedSync = useStoreReplayRouteSync(existingStore, relayRemote, {label: "relay"}) // same store, switchRoute() replaces the route after catch-up
|
|
457
|
+
const routedMirror = useStoreReplayRouteMirror(relayRemote, initial, {label: "relay"}) // mirror store + route hand-off controller
|
|
458
|
+
|
|
459
|
+
// per-key fold over the same line (Observe.syncStoreReplayEach counterpart): cb per CHANGED top-level key,
|
|
460
|
+
// first delivery = keyframe expanded per key, (key, undefined) = deleted — grid rows without special cases
|
|
461
|
+
const feed = useStoreReplayEach<Rows>(remote, (key, row) => row === undefined ? removeRow(key) : upsertRow(key, row), {drain?, initial?})
|
|
462
|
+
feed.store; feed.ready; feed.stale; feed.seq(); feed.restart() // same controller surface as the mirror
|
|
463
|
+
// the mirror store lives in a ref: in-mount resubscribes (StrictMode/restart/enabled) reconnect by tail ON TOP of
|
|
464
|
+
// kept state — the consumer sees only the diff. After a full unmount: {since: prev.seq(), initial: prev.store.snapshot()}
|
|
465
|
+
|
|
466
|
+
// pull at YOUR pace (frame model): timer around remote.frame(seq, hint) — server condenses, no backlog, no live socket sub
|
|
467
|
+
const pf = useReplayFrame(remote, (quote) => apply(quote), {intervalMs: 500, hint?})
|
|
468
|
+
pf.ready; pf.error; pf.seq(); pf.pull(); pf.restart() // error (e.g. sacred line evicted) STOPS pulling until restart()
|
|
469
|
+
|
|
470
|
+
// time machine over Replay.openHistory(storage, live?)
|
|
471
|
+
const tt = useReplayHistory(history, (frame) => draw(frame), {head: () => replay.head()})
|
|
472
|
+
tt.live; tt.seq; tt.head; tt.pause(); tt.play(); tt.seek({seq})
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Route hand-off here is the MANUAL surface (`switchRoute`). common2 1.0.67 `Replay.createRouteCoordinator` moves route decisions (policy, promote/fallback/shadow) out of the consumer; a coordinator `link.subscribe(cb)` handle has the same shape (`ready, seq(), label(), active()`) and survives every route change, so components consuming a coordinator link do not need `switchRoute` at all. common2 1.0.68 supplies the direct transport for it: `createWebRtcConnector` with an app-injected `rtc: () => new RTCPeerConnection(cfg)` factory (the browser — i.e. the React app — owns that injection) and signaling over the existing RPC socket (`createSignalHub`). common2 1.0.69 wraps the whole stack into the `Peer` SDK (`wenay-common2/peer`): `createPeerClient(...).peer(account)` returns a live mirrored store (works with `useStoreNode`/`useStoreKeys` as-is) + route control, surviving relay<->direct hand-offs in one seq space. `usePeer(client, account)` is the thin React adapter: it returns that mirror plus low-frequency route/status and explicit route/resync controls; it does not own journal, repair or transport state. `usePeerCalls(manager)` binds a `Peer.createCallManager` to rings/active/call UI state without owning `manager.close()` or signal policy. The exact interactive components used by QA cards 41–44 ship from `wenay-react2/demo/peer-media`; [`doc/examples/peer-call-media.tsx`](examples/peer-call-media.tsx) shows the import. `usePeerPresence(fragment.presence)` subscribes before reading the host snapshot and exposes online/offline edges. For calls with media, wire `Peer.createMediaRelay` on the server (`publishOf(owner)`, `watchOf(watcher)`, `canWatch`) and attach viewers only when the server-owned call policy grants access; React must never make the ACL decision.
|
|
476
|
+
|
|
477
|
+
Media lines (common2 1.0.66 `Media.createAudioSource` / `Media.createVideoSource`) are ordinary binary Listen sources; with `replay:true` their `listen` is a replay line, so `useReplaySubscribe` / `useReplayFrame` consume mic/camera frames with no media-specific hook. `useMediaSource(kind, options)` is only the capture lifecycle adapter (`start`, `stop`, device selection, state and stats); it stops a started source on unmount and returns `listen` unchanged. Each frame is one `Uint8Array` (`Media.decodeMediaFrame`); draw/play it via ref (canvas, AudioContext), never useState — the same rule as any high-frequency line. Without `replay`, the plain `listen` works with the listen hooks above.
|
|
478
|
+
|
|
479
|
+
Contract: `off()` on unmount, StrictMode-safe; seq survives resubscribes inside one mount (keepSeq, default on) — a resubscribe reconnects with `{since}` and gets the journal tail, not a keyframe. Across a FULL unmount/remount keep the position outside via `onSeq` and pass it back as `since`. `seq()` is a getter — high-frequency lines (video frames, ticks) do not re-render per event; draw to canvas via ref, bypassing VDOM. Freshness: detection lives in wenay-common2 (`staleMs` watchdog); the non-route hooks mirror its edge-triggered `onStale` into `stale`, so a fresh 100 ev/s line causes zero extra renders. Route hand-off is explicit through `switchRoute(nextRemote, {label?, since?, reset?, policy?, hint?})`: old route stays live while the replacement catches up by `seq`, then closes. `useReplayHistory` is archive playback — staleness does not apply. QA cards 23 (video line + conflation + time travel + freshness), 24 (store sync), 25 (per-key feed), and 26 (route hand-off) are the live examples.
|
|
480
|
+
|
|
481
|
+
## Logs
|
|
482
|
+
```
|
|
483
|
+
logsApi.addLogs({id: "api", time: new Date(), txt: "done", var: 1})
|
|
484
|
+
<logsApi.React.Message />
|
|
485
|
+
<logsApi.React.PageLogs />
|
|
486
|
+
<logsApi.React.Setting />
|
|
487
|
+
|
|
488
|
+
const customLogs = getLogsApi<MyFields>({limitPer: 500, limit?: 50, varMin?: 0, settingsKey?: "myLogs"})
|
|
489
|
+
// each getLogsApi call now owns ISOLATED full/mini/settings state (settingsKey persists the
|
|
490
|
+
// instance settings; omitted = fresh non-persisted). The global logsApi keeps the legacy shared state.
|
|
491
|
+
const headless = createLogsController<MyFields>({options: {limitPer: 500, limit: 50}})
|
|
492
|
+
|
|
493
|
+
const messageNotifications = useMessageEventLogsController({maxVisible: 4})
|
|
494
|
+
<MessageEventLogsView controller={messageNotifications} zIndex={80} />
|
|
495
|
+
|
|
496
|
+
const contextTable = useLogsTableController()
|
|
497
|
+
const contextNotifications = useLogsNotificationsController()
|
|
498
|
+
|
|
499
|
+
const mini = useMiniLogsTable({data: rows, onClick: e => console.log(e.data)})
|
|
500
|
+
<AgGridTable {...mini.props} />
|
|
501
|
+
<MiniLogsTable data={rows} />
|
|
502
|
+
|
|
503
|
+
const table = useLogsPageTable() // full-page logs grid controller
|
|
504
|
+
<AgGridTable {...table.gridProps} />
|
|
505
|
+
table.fit(); table.applyImportanceFilter(min?); table.appendRow(row); table.getApi()
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
Logger notification/tabs chrome uses `--logs-*` CSS variables; apps can theme it without forking logger components. `createLogsController` owns the headless append/limit/settings state for custom integrations. `useMessageEventLogsController` owns the global notification queue/timers/settings, `MessageEventLogsView` draws it, and `MessageEventLogs` / `logsApi.React.Message` remain compatibility wrappers. `MiniLogs` remains the compatibility wrapper; new compact-table code can use `useMiniLogsTable` or `MiniLogsTable`. `useLogsPageTable` owns the full-page table (mount-time row snapshot, transaction appends from the mini feed, one importance-filter method); `PageLogs` / `logsApi.React.PageLogs` remain the thin compatibility wrappers over it.
|
|
509
|
+
|
|
510
|
+
## Styles / Theme
|
|
511
|
+
```
|
|
512
|
+
tokens.color.bgDark
|
|
513
|
+
tokens.menu.outlineColor
|
|
514
|
+
tokens.logs.notificationAccent
|
|
515
|
+
tokens.zIndex.modal
|
|
516
|
+
|
|
517
|
+
GridStyleDefault() // inject legacy grid CSS vars/classes
|
|
518
|
+
StyleGridDefault // common ag-grid style object
|
|
519
|
+
buildAgTheme("dark" | "light")
|
|
520
|
+
useAgGridTheme("dark" | "light")
|
|
521
|
+
colDefCentered / colDefWrap / numericComparator
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
Shared CSS variables include `--menu-outline-color`, `--logs-*`, `--dlg-*`, `--wnd-*`, `--tb-*`, `--cols-grid-*`, `--cols-menu-*`, `--cols-dots-*`, and `--cols-card-*`.
|
|
525
|
+
|
|
526
|
+
## Charts
|
|
527
|
+
```
|
|
528
|
+
<MyChartEngine style={{height: 400}} />
|
|
529
|
+
createChartCanvas(config) -> canvas controller
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
The chart engine surface is still mostly low-level. Keep product-specific chart wrappers in the app.
|
|
533
|
+
|
|
534
|
+
## QA Stand
|
|
535
|
+
```
|
|
536
|
+
npm run testReact // http://localhost:3010/
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
The stand lives in `src/common/testUseReact/qa.tsx`. Use it for visual checks; agGrid4 overlay/dynamic-column demos are dedicated QA cards.
|
|
540
|
+
Two tabs: Active checks (current work) and Verified archive (`#archive`) - verified/fixed cards move
|
|
541
|
+
there and stay runnable for regression re-checks.
|