wenay-react2 1.0.50 → 1.0.51

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.
@@ -207,8 +207,10 @@ Use plain `AgGridTable` for declarative `rowData`.
207
207
  Use `useAgGrid` or `createGridBuffer` when row updates arrive as patches,
208
208
  streams, or imperative transactions.
209
209
 
210
- Use overlay mode when React-owned `rowData` owns the row set and external
211
- patches should update only already-present rows.
210
+ Use overlay mode when React-owned `rowData` owns the row set and external
211
+ patches should update only already-present rows. Overlay patches are merged with
212
+ the current grid row before delivery, so a `Partial<Row>` stream cannot erase
213
+ static fields owned by declarative `rowData`.
212
214
 
213
215
  Use `createColumnBuffer` for generic dynamic column-name lifecycle. The shared
214
216
  buffer stores names and replays them; the app wrapper decides target group,
@@ -0,0 +1,10 @@
1
+ # v1.0.51
2
+
3
+ Date: 2026-07-14
4
+
5
+ - Fixed: overlay `createGridBuffer` delivery now merges a `Partial<Row>` stream patch with the current AG Grid row in both immediate updates and `sync()`. Declarative `rowData` fields no longer disappear.
6
+ - Fixed: `useLogsPageTable` reconciles AG Grid with the bounded full-log controller state, including retention evictions, instead of only appending mini-feed rows forever.
7
+ - Fixed: `useReplaySubscribe` rejects an invalid Replay remote without its `line` surface as a local subscription error instead of allowing an uncaught render-time crash to blank the QA stand.
8
+ - QA: removed the unfinished real-RPC reconnect card from the stand while its server descriptor is incompatible with the installed common2 RPC surface; the rest of the stand remains usable.
9
+
10
+ Verification: focused Jest regression, full Jest (27 suites / 92 tests), `npx tsc -p tsconfig.qa-check.json --noEmit`, `npm run build`, `git diff --check`, and a visible Vite QA-stand reload.
@@ -0,0 +1,120 @@
1
+ # wenay-common2 1.0.75 — adoption audit (ultracode, 2026-07-13)
2
+
3
+ Аудит рабочей копии после бампа 1.0.74 → 1.0.75 (RPC/Replay reconnect contract): 4 параллельных аудитора
4
+ (новая карточка 47 / хуки useReplay / старые стенды / тест+доки), каждая находка прошла адверсариальную
5
+ верификацию отдельным агентом по реальному коду `node_modules/wenay-common2`. Итог: 26 подтверждено, 0 ложных.
6
+
7
+ ## Что уже сделано в рабочей копии (некоммичено)
8
+
9
+ - Бамп `wenay-common2` до `^1.0.75`, фикс `useReplaySubscribe` (флаг `failed`: `off.ready` в 1.0.75 резолвится
10
+ даже при терминальном `onError`, гонка ready=true закрыта), карточка 47 `ReplayRpcReconnectDemo`,
11
+ Socket.IO QA-эндпоинт `/__qa/replay-rpc` в vite.config.ts, тест `__test/useReplayRpcReconnect.test.tsx`, доки.
12
+ - Проверено сейчас: tsc qa-check чистый; jest 26 suites / 91 tests зелёные (reconnect-suite 4/4).
13
+
14
+ ## Подтверждено корректным (менять не надо)
15
+
16
+ - Ядро карточки 47: `hub.setToken(null)` — легитимный первичный connect (первый вызов не рвёт поколение);
17
+ `io({reconnection: false})` + ручные `socket.disconnect()/connect()` — настоящий транзиентный reconnect
18
+ для хаба; `connectListen/disconnectListen` без утечек под StrictMode; серверная обвязка в vite.config
19
+ (SocketTmpl-адаптер, `replay: 'auto'`, `line.count()`, один io на httpServer) корректна.
20
+ - Старые стенды НЕ содержат устаревших restart/remount-обходов: remount-via-since (карточка 23),
21
+ remount client (33), restart (24) — легитимные API-паттерны и при 1.0.75 (авто-восстановление покрывает
22
+ только transient reconnect живой подписки). Single-slot `onConnect/onDisconnect` нигде не используется.
23
+ - Хуки не сбрасывают состояние при транзиентном reconnect (эффекты завязаны на identity remote — она
24
+ в 1.0.75 стабильна); stale-логика контракту не противоречит; useReplayHistory не затронут.
25
+ - Карточка 25 — эталон, не затрагивалась.
26
+
27
+ ## Баги (код хуков)
28
+
29
+ 1. **`useStoreReplaySync` без `failed`-фикса** (`src/common/src/hooks/useReplay.ts:389`; наследуют
30
+ `useStoreReplayMirror`/`useStoreReplayEach`, карточки 24/33). `off.ready` у `syncStoreReplay` РЕЗОЛВИТСЯ
31
+ при терминальном `onError` (settleReady внутри closeSubscription) → `ready=true` на мёртвой подписке.
32
+ Фикс: `failed`-флаг + `if (!alive || failed) return` в `ready.then` **и** `setReady(false)` в `onError`
33
+ (иначе ошибка после уже наступившего ready оставляет true). На in-proc карточках 24/33 почти
34
+ не воспроизводится (keyframe всегда есть), но хуки публичные и принимают RPC-remote.
35
+ 2. **Route-хуки после post-ready смерти линии оставляют `ready=true`** (`useReplay.ts:268` —
36
+ `useReplayRouteSubscribe`, `:464` — `useStoreReplayRouteSync`): `onError` делает только `setError`,
37
+ React-состояние `ready=true`/`phase=='ready'` при `active()==false`. Геттер `active()` снаружи честен
38
+ (subRef), рассинхронизировано именно React-state. Поведение библиотеки не ново (с 1.0.67).
39
+
40
+ ## Карточка 47 — недоделки QA-стенда
41
+
42
+ 3. **Индикатор «stable remote» тавтологичен** (`replayVideo.tsx:444`): сравниваются два присваивания
43
+ одного объекта при первом setToken; `hub.facade.qaReplay.func.events` после reconnect не перечитывается —
44
+ главная новая гарантия 1.0.75 (identity прокси через reconnect) карточкой НЕ проверяется.
45
+ Фикс: в `connectListen` перечитывать `func.events` и сравнивать с `firstRemote`.
46
+ 4. **«mount consumer» после unmount необратимо ломает PASS** (`replayVideo.tsx:479`): фиксированный
47
+ `since: 0` + заглушка `onSeq: () => {}` → повторный маунт заливает всю историю в append-only метрики
48
+ (duplicates>0, ordered=false, reset нет). Фикс: remount-via-since (паттерн карточки 23 и doc-комментария
49
+ useReplay.ts:24-25) либо сброс metrics при mount.
50
+ 5. **do-текст «wait until it is green» невыполним** (`qa.tsx:1667` + `replayVideo.tsx:469` «wait for
51
+ delivery»): авто-refresh нет, снапшот обновляется только по кликам/connect-событиям. Фикс: текст
52
+ «press refresh metrics until green» либо периодический refresh.
53
+ 6. minor: счётчик errors append-only и смешивает три источника (офлайн-отказы обычных RPC из `run()` —
54
+ контрактное поведение 1.0.75, терминальный onError подписки, провал setToken) — случайный клик
55
+ start/stop/burst офлайн навсегда красит карточку; нужен reset или разделение источников
56
+ (`replayVideo.tsx:462`).
57
+ 7. minor: офлайн-ветка `refresh()` обнуляет серверные числа (`produced 0 · seq N/-1`) вместо last-known
58
+ со stale-пометкой; требование `listeners == 1` ломается вторым открытым табом QA-борда — нигде
59
+ не оговорено (`replayVideo.tsx:419`).
60
+ 8. minor: `vite.config.ts:213` — close-хук не делает `clearInterval(qaReplayTimer)`: при рестарте конфига
61
+ запущенный продюсер утекает (вечный 50мс-тик + удержание осиротевшей линии на каждое поколение).
62
+ 9. minor: qaReplay — sacred-линия (нет current/keyframe) с history 20000: после >20000 событий свежий
63
+ маунт с since:0 получает терминальный onError; рестарт dev-сервера при живом клиенте → seq>head →
64
+ терминальная ошибка после reconnect. Оба поведения контрактно правильные, но note карточки 47
65
+ их не оговаривает — QA увидит необъяснимый красный (`vite.config.ts:10`).
66
+
67
+ ## Гэпы адаптации хуков
68
+
69
+ 10. **`useReplayFrame`** (`useReplay.ts:691`): любая ошибка pull терминальна (clearInterval до ручного
70
+ `restart()`) — транзиентный блип останавливает опрос навсегда, хотя identity remote теперь стабильна
71
+ и есть `connectListen`/lifecycle для re-arm. Не различает sacred eviction и transient disconnect.
72
+ JSDoc подаёт как by-design — позиция 1.0.74-эпохи.
73
+ 11. **`useStoreMirror`** (`useObserveStore.ts:263`): неудачный `sync()` при disconnect терминален,
74
+ reconnect-ресинка нет. Хук намеренно транспорт-агностичен — минимум: задокументировать внешний
75
+ паттерн `hub.connectListen(() => controller.sync())` и/или опциональный параметр.
76
+ 12. doc: JSDoc хуков не фиксирует контракт 1.0.75 — терминальность `onError` и то, что transport
77
+ reconnect common2 восстанавливает сам (шапка useReplay.ts:15-36 неполна аддитивно; `onError` на :52
78
+ и `error` на :81 вовсе без JSDoc).
79
+ 13. minor: мёртвые rejection-ветки `off.ready.then(..., e => ...)` в `useReplaySubscribe` (:162) и
80
+ `useStoreReplaySync` (:395) — ready в 1.0.75 никогда не реджектится (у route-хуков ветка рабочая).
81
+
82
+ ## Гэпы теста `__test/useReplayRpcReconnect.test.tsx`
83
+
84
+ Тест честный (реальный Socket.IO, без моков), но не покрывает на уровне React-обёртки:
85
+ 14. hard teardown: живая подписка → `dispose()`/`setToken()` → подписка не воскресает через effect (:72);
86
+ 15. стабильная identity прокси: `expect(clients.replay.func.ticks).toBe(remote)` после reconnect —
87
+ сейчас проверяется только физический сокет; assert нужен внутри fixture.reconnect, где clients
88
+ в замыкании (:50);
89
+ 16. eviction внутри офлайн-дыры: доставка до seq N → disconnect → эмит сверх маленького history →
90
+ reconnect → терминальная ошибка (ready true→false), без ложного продолжения (:225 покрывает только
91
+ initial subscribe);
92
+ 17. «unsubscribing offline prevents resurrection»: disconnect → unmount → reconnect → consumers==0 (:158);
93
+ 18. multi-consumer dedup recovery (два хука на одном remote → serverConsumers()==1, оба точны после
94
+ reconnect) и 2-3 повторных reconnect-поколения (:117).
95
+
96
+ ## Доки
97
+
98
+ 19. Шапка `replayVideo.tsx:3` «Everything is in-proc» устарела — карточка 47 в этом же файле на реальном
99
+ Socket.IO (docstring самой карточки корректен; поправить только охват шапки: in-proc — карточки 40-46).
100
+ 20. `doc/wenay-react2-rare.md:558` — осиротевшая строка `**Identity matrix**` без содержимого (обрубок).
101
+ 21. `doc/changes/v1.0.50.md:5` — Changed замалчивает фикс `useReplaySubscribe` (критично), карточку 47 и
102
+ QA-эндпоинт (упомянуты лишь косвенно в Verification).
103
+ 22. Карточка 47 не добавлена в списки live-примеров (`doc/wenay-react2.md:481` «cards 23…26»,
104
+ `doc/wenay-react2-rare.md:577`).
105
+ 23. `doc/EXAMPLE_USAGE.md:819` — retention журнала ошибочно приписан policy `queue` (retention задаёт
106
+ продюсер `replayListen({history})`; `queue` — только «wire-подписка ничего не пропускает при лаге»).
107
+ 24. minor: лишняя вторая пустая строка после `## Replay Feed Into A Grid` (`doc/EXAMPLE_USAGE.md:834`).
108
+ 25. gap стенда: терминальный sacred-onError + восстановление явным `restart(since)` не демонстрируется
109
+ ни одной интерактивной карточкой (автотест покрывает только терминальность на initial subscribe);
110
+ все линии стендов имеют keyframe/history с запасом (`replayVideo.tsx:32`).
111
+
112
+ ## Порядок работ (предложение)
113
+
114
+ 1. Баги хуков (№1-2) — фикс + симметричные тесты (по образцу eviction-теста 4).
115
+ 2. Карточка 47 до честного PASS (№3-9: stable remote, mount consumer, do-текст; minor — по ходу).
116
+ 3. Тестовые гэпы (№14-18) — один batch в reconnect-suite.
117
+ 4. Доки (№19-24) одним доко-проходом; №10-12 (useReplayFrame re-arm, mirror resync, JSDoc) — отдельный
118
+ осознанный pass, №25 (sacred-карточка) — опционально после него.
119
+
120
+ Verify каждого шага: tsc qa-check, jest, build, стенд 3010 (карточки 23/24/33/47).
package/doc/target/my.md CHANGED
@@ -2,9 +2,17 @@
2
2
 
3
3
  Правила ведения: [README.md](README.md). Ниже — статус по задачам; **исходные надиктовки сохранены дословно в конце файла** (ничего не удаляю).
4
4
 
5
- ## In Progress
6
-
7
- - **Конференц-демо: групповой созвон на обеих технологиях relay-мост + WebRTC direct.** DONE 2026-07-13 (обе надиктовки 2026-07-12, дословно в конце файла).
5
+ ## In Progress
6
+
7
+ - **Reliability sweep 2026-07-14: replay keyframes and bounded grid delivery.** Source: dictated priority list.
8
+ - `clientBacktest/PanicPanel`: resume `OrderEvents` from the retained replay window/keyframe, never hard-code `since: 0`; opening after the 256-event retention window must recover.
9
+ - `serverAdmin balances`: publish a complete current-state keyframe and recover after journal gaps; a newly connected client must see balances before another event arrives.
10
+ - `wenay-react2 Grid overlay` — implemented: merge a `Partial<Row>` patch with the grid-owned row before sending an AG Grid update, so static fields survive.
11
+ - `wenay-react2 LogsPage` — implemented: remove rows displaced by the bounded controller feed from the live grid; the visible grid stays bounded.
12
+ - Local verification: overlay regression test, full Jest 27/92, `tsc -p tsconfig.qa-check.json --noEmit`, build, `git diff --check`; QA stand is running on `http://127.0.0.1:3010/`.
13
+ - Remaining: targeted recovery proof for `PanicPanel` and the coordinated serverAdmin/client balance-keyframe contract.
14
+
15
+ - **Конференц-демо: групповой созвон на обеих технологиях — relay-мост + WebRTC direct.** DONE 2026-07-13 (обе надиктовки 2026-07-12, дословно в конце файла).
8
16
  - Отгружено: `wenay-react2/demo/peer-conference` — headless `createConferenceWorld` (host-star: групповой звонок КОМПОНУЕТСЯ из парных, один CallManager держит N-1 исходящих; roster активных звонков = единственный ACL-авторитет) + `ConferenceCallDemo` (QA card 46: грид на `Peer.createMediaRelay` fan-out, focus-пара на `Replay.createRouteCoordinator` — relay-hop `serveReplayChannel` и WebRTC `createWebRtcConnector`/`acceptWebRtcDirect` обслуживают ОДНУ owner-seq линию, hand-off без разрывов по seq; никогда не сервить маршрут из `relay.watchOf` — per-watcher seq). Хук `useRouteState`. Fake-RTC loopback (порт оракула common2) для jest/песочниц.
9
17
  - **Через бэк (надиктовка №2)**: `doc/examples/conference-server.mjs` (Node+Socket.IO: peer host, комнатная политика — accepted call вступает обеими сторонами в room, offer брокерится только внутри общей комнаты, media relay с `canWatch`=sameRoom) + `conference-client.html/.ts` (одно место = одна вкладка: грид через серверный relay, peer-store строки с promoteDirect в настоящий cross-tab RTCPeerConnection и reinterpose обратно).
10
18
  - Проверено: tsc, Jest 25/87 (9 новых), build; карточка 46 пройдена живьём на РЕАЛЬНОМ RTCPeerConnection (promote #153→#174 без разрыва, policy-отказы, server revoke, kill transport → auto-fallback); бэкенд-вариант — две вкладки, cross-tab direct (beat 47→51→69 gap-free), сервер переживает mid-room disconnect. Durable: `doc/changes/v1.0.49.md`, brief-док §Route, examples README.
@@ -444,7 +444,8 @@ colDefWrap
444
444
  Important agGrid4 contracts:
445
445
  ```
446
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.
447
+ overlay mode: rowData owns row set; sync updates only existing grid rows.
448
+ Partial patches merge with that current row before delivery.
448
449
  clean(): clears buffer; mirror also removes grid rows, overlay does not remove declarative rows.
449
450
  flush(): React/controller method that calls ag-grid flushAsyncTransactions().
450
451
  ```
@@ -609,16 +610,16 @@ MainPage()
609
610
 
610
611
  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.
611
612
 
612
- Full-page table controller (`useLogsPageTable` -> `LogsPageTableController`): the last logs table
613
- moved to the controller pattern. Semantics that matter: the grid receives a MOUNT-TIME snapshot of
614
- the accumulated log map once (`useState` initializer) and afterwards lives on
615
- `applyTransactionAsync` appends from the mini feed - re-renders do not re-feed `rowData`, so row
616
- identity is per-append copy exactly as before. The importance filter is ONE method
613
+ Full-page table controller (`useLogsPageTable` -> `LogsPageTableController`): the grid receives a
614
+ MOUNT-TIME snapshot of the accumulated log map once (`useState` initializer), then reconciles
615
+ `applyTransactionAsync` adds AND removes with the controller's bounded full map; re-renders do not
616
+ re-feed `rowData`. `num` is the stable grid row id, so a per-source retention eviction removes the
617
+ same record from AG Grid instead of allowing the live table to grow forever. The importance filter is ONE method
617
618
  (`applyImportanceFilter(min?)`: set -> `setFilterModel` greaterThanOrEqual on `var`, unset ->
618
619
  `destroyFilter`), used by both the settings subscription and `onGridReady` (which skips the
619
620
  destroy branch - a fresh grid has no filter). Both data subscriptions are `updateBy(obj, cb)`
620
621
  WITH a callback: they run imperatively on `renderBy` and never re-render the component.
621
- `gridProps` is one stable memo - spread it into `AgGridTable`; `fit()` is exposed for the legacy
622
+ `gridProps` is one stable memo - spread it into `AgGridTable`; `fit()` is exposed for the legacy
622
623
  `update` prop, which `PageLogs` still honors. QA card 9.
623
624
 
624
625
  ## Cache / Memory / Browser Utilities
@@ -507,7 +507,7 @@ const table = useLogsPageTable() // full-page logs grid controll
507
507
  table.fit(); table.applyImportanceFilter(min?); table.appendRow(row); table.getApi()
508
508
  ```
509
509
 
510
- 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.
510
+ 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 reconciliation to the controller's bounded state, one importance-filter method); `PageLogs` / `logsApi.React.PageLogs` remain the thin compatibility wrappers over it.
511
511
 
512
512
  ## Styles / Theme
513
513
  ```
@@ -60,7 +60,12 @@ export function createGridBuffer(deps) {
60
60
  if (seen.has(id))
61
61
  continue;
62
62
  seen.add(id);
63
- const merged = buf[id];
63
+ // AG Grid replaces a transaction row. Overlay streams may send a
64
+ // Partial<T>, so preserve static fields from the rowData-owned row.
65
+ const node = mode == 'overlay' ? api.getRowNode?.(id) : undefined;
66
+ const merged = node?.data
67
+ ? Object.assign({}, node.data, buf[id])
68
+ : buf[id];
64
69
  if (gridHas(id))
65
70
  toUpdate.push(merged);
66
71
  else
@@ -104,7 +109,7 @@ export function createGridBuffer(deps) {
104
109
  return;
105
110
  const id = getId(node.data);
106
111
  if (buf[id])
107
- update.push(buf[id]);
112
+ update.push(Object.assign({}, node.data, buf[id]));
108
113
  });
109
114
  if (update.length)
110
115
  api.applyTransaction({ update });
@@ -49,6 +49,13 @@ export function useReplaySubscribe(remote, cb, options = {}) {
49
49
  useEffect(() => {
50
50
  if (!remote || !enabled)
51
51
  return;
52
+ if (!remote.line || typeof remote.line.on != 'function') {
53
+ const error = new Error('Replay remote is missing its line surface');
54
+ setReady(false);
55
+ setError(error);
56
+ hooksRef.current.onError?.(error);
57
+ return;
58
+ }
52
59
  if (lastRemoteRef.current !== undefined && lastRemoteRef.current !== remote)
53
60
  seqRef.current = undefined; // a different line — old seq is meaningless
54
61
  lastRemoteRef.current = remote;
@@ -55,7 +55,7 @@ export declare function useLogsPageTable(state?: LogsViewState): {
55
55
  getApi: () => GridReadyEvent<any, any> | null;
56
56
  fit: () => void;
57
57
  applyImportanceFilter: (min?: number) => void;
58
- appendRow: (row: LogRow) => void;
58
+ syncRows: () => void;
59
59
  onGridReady: (a: GridReadyEvent<LogRow>) => void;
60
60
  columnDefs: ({
61
61
  field: string;
@@ -96,6 +96,9 @@ export declare function useLogsPageTable(state?: LogsViewState): {
96
96
  rowHeight: number;
97
97
  autoSizePadding: number;
98
98
  rowData: any[];
99
+ getRowId: (p: {
100
+ data: LogRow;
101
+ }) => string;
99
102
  columnDefs: ({
100
103
  field: string;
101
104
  sort: "desc";
@@ -80,10 +80,10 @@ function InputSettingLogs({ settings }) {
80
80
  export function useLogsPageTable(state) {
81
81
  const setting = state?.settings ?? memoryGetOrCreate("settingLogs", settingLogs);
82
82
  const full = state?.full ?? datumConst;
83
- const mini = state?.mini ?? datumMiniConst;
84
83
  const apiGrid = useRef(null);
85
84
  // mount-time snapshot: the live grid is fed by transactions, not re-renders
86
85
  const [rowData] = useState(() => [...full.map.values()].flat());
86
+ const shownRows = useRef(new Map(rowData.map(row => [row.num, row])));
87
87
  const getApi = useCallback(() => apiGrid.current, []);
88
88
  const fit = useCallback(() => { apiGrid.current?.api.sizeColumnsToFit(); }, []);
89
89
  const applyImportanceFilter = useCallback((min) => {
@@ -103,26 +103,33 @@ export function useLogsPageTable(state) {
103
103
  api.destroyFilter("var");
104
104
  }
105
105
  }, []);
106
- const appendRow = useCallback((row) => {
107
- apiGrid.current?.api.applyTransactionAsync({ add: [row] });
108
- }, []);
106
+ const syncRows = useCallback(() => {
107
+ const api = apiGrid.current?.api;
108
+ if (!api)
109
+ return;
110
+ const next = new Map([...full.map.values()].flat().map(row => [row.num, row]));
111
+ const add = [...next].filter(([num]) => !shownRows.current.has(num)).map(([, row]) => row);
112
+ const remove = [...shownRows.current].filter(([num]) => !next.has(num)).map(([, row]) => row);
113
+ shownRows.current = next;
114
+ if (add.length || remove.length)
115
+ api.applyTransactionAsync({ add, remove });
116
+ }, [full]);
109
117
  // settings change -> single filter method (no re-render: updateBy with a callback)
110
118
  updateBy(setting, () => {
111
119
  applyImportanceFilter(setting.params.minVarLogs);
112
120
  });
113
- // new mini-feed entry -> async append, copy like before (row identity per event)
114
- updateBy(mini, () => {
115
- const data = mini.last[0];
116
- if (data)
117
- appendRow({ ...data });
121
+ // The full state owns retention, so reconcile additions and evictions together.
122
+ updateBy(full, () => {
123
+ syncRows();
118
124
  });
119
125
  const onGridReady = useCallback((a) => {
120
126
  apiGrid.current = a;
127
+ syncRows();
121
128
  fit();
122
129
  // fresh grid has no filter - only apply when the setting asks for one
123
130
  if (setting.params.minVarLogs)
124
131
  applyImportanceFilter(setting.params.minVarLogs);
125
- }, [applyImportanceFilter, fit, setting]);
132
+ }, [applyImportanceFilter, fit, setting, syncRows]);
126
133
  const columnDefs = useMemo(() => [
127
134
  {
128
135
  field: "time",
@@ -169,6 +176,7 @@ export function useLogsPageTable(state) {
169
176
  rowHeight: 26,
170
177
  autoSizePadding: 1,
171
178
  rowData,
179
+ getRowId: (p) => String(p.data.num),
172
180
  columnDefs,
173
181
  onCellMouseDown: (e) => {
174
182
  if (e.event instanceof MouseEvent && e.event.button == 2) {
@@ -180,7 +188,7 @@ export function useLogsPageTable(state) {
180
188
  }
181
189
  },
182
190
  }), [columnDefs, onGridReady, rowData]);
183
- return { getApi, fit, applyImportanceFilter, appendRow, onGridReady, columnDefs, gridProps };
191
+ return { getApi, fit, applyImportanceFilter, syncRows, onGridReady, columnDefs, gridProps };
184
192
  }
185
193
  export function PageLogs({ update, state }) {
186
194
  const table = useLogsPageTable(state);
@@ -14,7 +14,7 @@ import { DragBox } from "../src/components/Dnd/FloatingWindow";
14
14
  import { MyChartEngine } from "../src/myChart/chartEngine/chartEngineReact";
15
15
  import { GridExample, tt } from "./useGrid";
16
16
  import { TestParams } from "./testParams";
17
- import { ReplayVideoDemo, ReplayRouteDemo, ReplayRpcReconnectDemo, ReplayStoreDemo, ReplayStoreEachDemo } from "./replayVideo";
17
+ import { ReplayVideoDemo, ReplayRouteDemo, ReplayStoreDemo, ReplayStoreEachDemo } from "./replayVideo";
18
18
  import { PeerCallDemo, PeerPresenceDemo, MediaRelayAclDemo, MediaRelayAudioDemo, PeerCallVideoAudioDemo } from "../demo/peerMedia";
19
19
  import { ConferenceCallDemo } from "../demo/peerConference";
20
20
  /* ---------- card wrapper ---------- */
@@ -1084,7 +1084,7 @@ const BoardDemo = () => {
1084
1084
  };
1085
1085
  /* ---------- borad ---------- */
1086
1086
  function ActiveChecks() {
1087
- return (_jsxs(_Fragment, { children: [_jsx(Check, { n: 18, title: "Observe hooks - local store and listen", do: "Click node.at(count) +1, replace status, plain state mutation + flush, emit listen, and replace whole store.", expect: "Leaf node, selection, direct state mutation after flush, add/delete object keys, and listen hooks all rerender. The count key is read through node.at(count), so it does not conflict with node.count().", note: "This isolates the React adapter from transport: no fetch/SSE/RPC involved.", tall: true, children: _jsx(ObserveStoreLocalDemo, {}) }), _jsx(Check, { n: 17, title: "Observe hooks - store mirror over HTTP/SSE", do: "Click server +1, label, add/delete key, deep leaf/deep add/deep delete, local mirror +1000, stop sync, then manual sync.", expect: "Server buttons POST to the Vite QA server, including add/delete object keys and deep mutations. SSE pushes changedPaths; mirror pulls only the intersecting mask when possible. Local mirror edits render immediately and are overwritten by the next server sync.", note: "This checks the React adapter only in wenay-react2: common2 remains React-free; transport policy stays outside the hook.", tall: true, children: _jsx(ObserveStoreMirrorDemo, {}) }), _jsx(Check, { n: 23, title: "Replay hooks - video line, conflation, time travel, freshness", do: "Watch A and B play the same synthetic video. Toggle slow network for B, switch resolution, unmount/remount A. In C drag the slider (playback pauses), then press live. In D: note the renders counter while the line is fresh, check stall producer, wait 2s, uncheck; while stalled press new client (keyframe). In E: switch pull pace (250ms/1s/3s), press pull now.", expect: "A plays smoothly at 10 fps. B on slow network stays CURRENT (bounded latency): frames drop (dropped/coalesced counters grow), the wire buffer never grows past highWater, and each recovery is one coalesced last-frame envelope. Resolution switches on all clients within a frame. Remounting A continues from the kept seq (seq does not reset, frames counter continues from the tail). C seeks to any archived seq via keyframe+tail fold and hands over to live seamlessly. D: renders stays FLAT while frames grow (no per-event re-renders); on stall the STALE badge appears after ~2s and disappears on the first frame after resume; a client mounted during a stall goes STALE within staleMs (this in-proc keyframe is stamped at request time; a tail/keyframe carrying an old producer ts goes stale from the first paint); StrictMode double-effect leaves one watchdog and no badge flicker. E advances ONLY at the pull cadence: frames jumps by ~pace\u00D710fps per pull while pulls grows by one; seq keeps up with head; switching pace keeps the position (no keyframe restart, frames does not re-fold); pull now folds immediately.", note: "All in-proc: the socket transport is already proven in wenay-common2 (replay/video-socket.demo, canvas-socket.test). This card tests the React side: useReplaySubscribe lifecycle (off on unmount, reconnect by since), useReplayHistory scrubber, frames drawn to canvas via ref - bypassing VDOM, stale/staleMs mirroring common2's edge-triggered watchdog into React state, useReplayFrame pull path (timer around remote.frame(), rev2 frame model; policy:'frame'/hint ride ReplaySubscribeOpts but need a server frameLine - the wire test lives in common2 replay/rpc-auto.test.ts). The producer starts on first render and runs until page reload.", tall: true, children: _jsx(ReplayVideoDemo, {}) }), _jsx(Check, { n: 47, title: "Replay hooks - real RPC reconnect under StrictMode", do: "Keep this visible tab open. Start producer, wait until it is green, disconnect transport for a few seconds while the producer runs, press parent rerender burst, then reconnect. Run burst 5,000 and refresh after it drains. Finally unmount consumer and refresh.", expect: "The stable RPC remote survives disconnect/reconnect without restart/remount. After reconnect: every produced number is received exactly once in ascending order; missing and duplicates are zero; seq reaches head; there is exactly one active server listener. The 5,000-event queue burst increases delivery without a React render per event. After unmount, server listeners becomes 0.", note: "This is the transport proof missing from the in-process replay cards: Vite hosts a Socket.IO server with createRpcServerAuto and the card consumes its public RPC Replay remote. common2 owns reconnect/replay; React only mounts/off()s the stable remote. Do not interpret hidden-tab throttling as transport lag.", tall: true, children: _jsx(ReplayRpcReconnectDemo, {}) }), _jsx(Check, { n: 24, title: "Replay hooks - store sync (useStoreReplayMirror)", do: "Watch ticks/price advance. Click server note / add key / delete key. Uncheck sync enabled, mutate the server a few times, recheck. Click restart. Check stall producer, wait 2.5s, then click server note.", expect: "Mirror follows the server store with seq ascending. While sync is disabled the mirror freezes; on re-enable it catches up through the journal tail (seq jumps to head, no full reset flicker). Object key add/delete replicate. restart resubscribes from the kept seq. On stall the stale flag flips true after 2.5s and lastTs freezes; any server mutation (e.g. server note) flips it back to fresh.", note: "exposeStoreReplay/syncStoreReplay in-proc: the remote is the exposed {line, since, keyframe} facade, exactly what createRpcServerAuto would expose over a socket. staleMs rides the same ReplaySubscribeOpts as the video card.", children: _jsx(ReplayStoreDemo, {}) }), _jsx(Check, { n: 33, title: "Replay hooks - per-key feed (useStoreReplayEach)", do: "Watch the table for a few producer ticks. Click server add row, server delete row, server replace ALL, then remount client (fresh keyframe).", expect: "On mount every row appears with cb calls=1 (keyframe expanded per key). Between clicks only the mutated row's cb calls counter grows - the whole dict is never re-delivered per tick. Delete removes the row via (key, undefined). replace ALL swaps the table: removed rows leave, new rows enter with cb calls=1. Remount folds a fresh keyframe (all counters reset to 1); StrictMode double-effect does not double-count.", note: "React counterpart of Observe.syncStoreReplayEach (wenay-common2 1.0.62): internal mirror store + syncStoreReplay + store.each(). The mirror lives in a ref, so in-mount resubscribes reconnect by journal tail on top of kept state; the fold target is a plain Map (grid-api style), not React state. drain:100 coalesces multiple writes to one key into one call per window.", tall: true, children: _jsx(ReplayStoreEachDemo, {}) }), _jsx(Check, { n: 34, title: "Replay hooks - route hand-off (useReplayRouteSubscribe)", do: "Watch one canvas draw the synthetic video. Click switch direct, then switch relay, then fail route. Repeat while the producer is moving.", expect: "The canvas keeps advancing as one logical fold: route switches catch up by seq before the old route closes, so frames do not reset or duplicate. The label changes to direct/relay only after ready. fail route reports an error but keeps the previous active route alive and the canvas continues.", note: "React wrapper over wenay-common2 1.0.65 Replay.replayRouteSubscribe. Route hand-off is explicit through switchRoute(); changing the remote prop remains a fresh subscription boundary. This route helper does not expose stale/lastTs, so freshness stays on the non-route hooks until common2 grows that surface.", tall: true, children: _jsx(ReplayRouteDemo, {}) }), _jsx(Check, { n: 13, title: "ModalProvider / useModal - Escape and outside click", do: "Click open modal. Close it with Escape. Open it again and close with an outside click. Open it again and close with the close button.", expect: "All three methods close it. The dimmed backdrop is above everything (z-index from token --wenay-z-modal).", note: "M1: Escape and closeOnEscape/closeOnOutsideClick options were added; useModal remains the app-level path and createModalElementStore remains low-level.", children: _jsx(ModalDemo, {}) }), _jsx(Check, { n: 20, title: "SettingsDialog - searchable settings tree + registry", do: "Click the three-dot toolbar-style settings button: drag the window by its header, drag the divider between tree and content, double-click it to reset width, use the tree icons and the dotted tree-cycle button, search for suffix/leaf/palette/accent/font/external and wrong-layout examples like \u044B\u0433\u0430\u0430\u0448\u0447 for suffix. Press Enter to save a query into search history, reopen history from the clock button, pick a saved query, then clear history. Clear search via the x and via Escape, then close via window x/outside click/Escape with empty search. Unmount external module and open again.", expect: "The default trigger is the same compact toolbar-button style as createToolbar, using a three-dot icon. Dialog opens as the standard draggable FloatingWindow with a header, larger size, shared close x, and outside-click close. The tree/content divider changes the tree width, persists it through memoryCache, supports keyboard arrows, and double-click/Enter resets to default. Search uses original input plus RU/EN keyboard-layout variants, selects the first real match, auto-expands parents, and highlights only the matched word once. Enter stores non-empty queries in a small persisted search history; choosing a history item restores the query; clearing history removes the dropdown; leaving the search box closes the dropdown. The dotted tree-cycle button switches expanded/current branch/collapsed and stays in the search row. The clear x and Escape both cancel search text; Escape with empty search closes the dialog. The external child under Display appears only while mounted.", note: "Registry is a module singleton (registerSettingsSection -> unregister), no React context. Tree shape comes from children or parentKey. Search history uses createSearchHistory -> memoryGetOrCreate/memoryCache dirty channel; this demo loads memoryCache and saves dirty changes with saveDebounced(300). Look via --dlg-* tokens; apps pass their own section classes via sectionClassName/sectionActiveClassName.", children: _jsx(SettingsDialogDemo, {}) }), _jsx(Check, { n: 38, title: "Media video - capture lifecycle + canvas viewer", do: "Press start camera and grant permission. Move the tab to background for a short time, then return. Press stop, then start again.", expect: "The canvas renders without React re-rendering per frame. State jumps idle \u2192 live (or idle \u2192 denied without a crash; the hook sets state only after start() settles, so no intermediate 'requesting' is shown); the stats line shows drawn frames and frame age. Hidden-tab capture is owned by common2 worker/ImageCapture defaults.", note: "useMediaSource owns only permission/device lifecycle. Media.attachVideoCanvas owns JPEG decode and drawing; frame data never enters React state.", children: _jsx(MediaVideoDemo, {}) }), _jsx(Check, { n: 39, title: "Media audio - mic lifecycle + sequential player", do: "Press enable + start mic, grant permission, speak briefly, then stop. If browser autoplay blocks audio, press the same button again.", expect: "Audio activation happens in a user gesture; common2 player keeps a short live backlog and reports played/dropped frames. React renders only the half-second stats snapshot.", note: "The PCM player is common2 imperative code. This card proves the React wrapper's start/stop cleanup and gesture boundary, not an audio implementation in React.", children: _jsx(MediaAudioDemo, {}) }), _jsx(Check, { n: 40, title: "Peer SDK - mirrored store + explicit resync", do: "Click peer A +1 several times. Watch mirrored value on peer B. Press resync B; the mirror must stay coherent.", expect: "Peer SDK owns relay journal and repair. React reads the peer store normally and exposes route/ready/seq as low-frequency control state; no transport or patch protocol is reimplemented here.", note: "In-process host replaces a fake UI mock: this card proves the actual Peer.createPeerClient contract. Direct WebRTC needs the app's real signaling/rtc factory and remains a separate browser recipe.", children: _jsx(PeerSdkDemo, {}) }), _jsx(Check, { n: 41, title: "Peer calls - ring, accept, hangup", do: "Click call B. On B press accept; both sides become active. Then hang up from A. Repeat and decline on B.", expect: "The incoming ring, active state and terminal reason propagate through the existing Peer signal hub. React only renders manager state; common2 owns call IDs, busy/glare resolution and timeout/offline verdicts.", note: "In-process host, no fake call protocol: usePeerCalls binds Peer.createCallManager. A real app supplies its server-side authorize policy before allowing media viewers.", children: _jsx(PeerCallDemo, {}) }), _jsx(Check, { n: 42, title: "Peer presence - snapshot plus online/offline edges", do: "Observe both accounts online. Toggle B connection off and on.", expect: "The list updates on connection edges without polling. The hook subscribes before it reads the snapshot, so it follows the host protocol rather than creating a second presence store.", note: "Presence is common2 host state. React receives only list/edge data and does not decide authentication or account validity.", children: _jsx(PeerPresenceDemo, {}) }), " ", _jsx(Check, { n: 43, title: "Media relay - actual camera stream with live ACL revoke", do: "Start camera and grant permission: the right canvas must show the relayed stream. Revoke ACL: the viewer frame counter stops while capture keeps running. Grant it again: frames resume. Stop camera.", expect: "The path is camera \u2192 Media source \u2192 common2 relay \u2192 policy-filtered viewer canvas. ACL revocation gates an already-open viewer without trusting React to detach it; the source stats continue because publishing and viewing are separate responsibilities.", note: "This is an in-process relay, so it proves the React/lifecycle seam and live policy filter. A deployed app exposes publishOf/watchOf through its RPC server and keeps canWatch plus call authorization on the server.", children: _jsx(MediaRelayAclDemo, {}) }), " ", _jsx(Check, { n: 44, title: "Audio relay - actual microphone stream with live ACL revoke", do: "Press enable + start relay mic and grant permission, then speak. Revoke ACL: playback and viewer counters stop while capture remains live. Grant it again and confirm playback resumes.", expect: "The path is microphone \u2192 Media source \u2192 common2 audio relay \u2192 policy-filtered AudioContext player. Audio activation is a user gesture; ACL gates the existing viewer without React deciding access.", note: "This uses the relay's short lossless audio queue. In production publishOf/watchOf/canWatch are exposed by the server next to the call authorization policy.", children: _jsx(MediaRelayAudioDemo, {}) }), " ", _jsx(Check, { n: 45, title: "Peer call with live video and audio relay", do: "Enable camera + mic, call B, then accept on B. The canvas starts receiving video only after accept; speak to hear the relayed audio. Hang up: the viewer detaches.", expect: "One real scenario: call state gates server-style relay access and viewer lifecycle. Before/after the active call, capture may run but B receives no media.", note: "This is the complete in-process consumer demo exported from wenay-react2/demo/peer-media; production keeps the same ACL decision on its server.", children: _jsx(PeerCallVideoAudioDemo, {}) }), " ", _jsx(Check, { n: 46, title: "Conference: 3-way star room over the media relay + policy-routed direct focus", tall: true, do: "Ring conf-b and conf-c from the host and accept on both seats: all six grid tiles start moving (each seat watches the other two). Hang up conf-b: only its tiles freeze (live membership ACL); re-ring and re-accept to resume. In the focus panel pick an owner and press go direct: the chip walks relay -> direct:connecting -> direct while the frame counter stays strictly monotonic (no reset, no jump). Press back to relay. Toggle policy: force relay and promote again - denied with reason policy: mustRelay. Untoggle it, toggle server: refuse endpoint exposure and promote - the offer is rejected, the link lands in fallback and frames continue on relay. Clear the toggles, promote, then press server revoke: the live direct session dies server-side and the tile auto-falls back without losing frames. Re-promote and press kill direct transport: the same visible fallback from the transport side. If real WebRTC is unavailable here, keep simulate RTC checked - the loopback runtime negotiates the same signaling.", expect: "The grid is Peer.createMediaRelay fan-out with canWatch reading room membership derived from pairwise host-star calls (group calling is composed, not native: the host holds N-1 concurrent outgoing calls on ONE CallManager). The focus tile is Replay.createRouteCoordinator over ONE owner-sequenced line served by BOTH routes - an in-proc serveReplayChannel relay hop and a WebRTC datachannel via createWebRtcConnector/acceptWebRtcDirect - so every hand-off is gap-free by seq. Client policy hooks and the host authorize gate are separate boundaries and both fail loudly as result objects, never exceptions; server revoke and transport death both auto-fall back to relay.", note: "Frames are JSON snapshots because the replay channel wire is text (connector info binary: false); real-camera binary frames through attachVideoCanvas stay proven by cards 43-45. Exactly one host.connection per account: the signal hub delivers to the LAST registered port, and the call manager, webrtc connector and acceptor deliberately share it. Never route the coordinator through relay.watchOf lines - those are per-watcher re-sequenced journals and a hand-off would silently drop frames. Exported as wenay-react2/demo/peer-conference.", children: _jsx(ConferenceCallDemo, {}) }), " ", _jsx(Check, { n: 21, title: "createUiSlot - configurable block placement", do: "Switch Top bar / Sidebar. Then reload the page (F5).", expect: "The block moves between the two containers WITHOUT a reload; only one mount point shows it at a time. After F5 the chosen place is restored (memoryGetOrCreate -> memoryCache).", note: "Mount points render <Slot place=...> themselves and stay ignorant of each other. The demo calls memoryCache.load() on mount and subscribes memoryCache.onDirty -> saveDebounced(300): the persisted maps are observable and mark memoryCache dirty themselves, the app owns the write policy.", children: _jsx(UiSlotDemo, {}) }), _jsx(Check, { n: 22, title: "createCallbackHub - one slot, many subscribers", do: "Note slot bound = false. Click on A (bound becomes true), fire source event, then on B and fire again. Then off A and fire once more.", expect: "Before the first on() the slot is untouched (lazy bind). With A+B subscribed both receive the same events. After off A, B keeps receiving; hub.count() tracks subscribers.", note: "Fixes the real bug where two onX(cb) subscribers silently overwrote each other. Built on `listen` from wenay-common2; bind(emit) runs once.", children: _jsx(HubDemo, {}) }), _jsx(Check, { n: 25, title: "createToolbar - customizable toolbar (config / Bar / Settings)", do: "Click toolbar items (last action updates). Open the gear popover: toggle Clear workspace on, drag rows to reorder - grab ANYWHERE on the row, mouse or touch, try dragging above the fixed Home too (or focus the handle and press arrow keys), switch density Icons / Icons + labels. Open global settings -> Toolbar section and repeat an edit there. Register the 3rd density and switch to Full text. Uncheck the separated Toolbar settings row at the bottom - the gear (and this popover) disappears from the bar; re-enable it via global settings -> Toolbar. Click simulate app update. Reload the page (F5). In Settings, click the Reset toolbar action button inside its row. Then check the Reset toolbar row, confirm the reset icon appears in the bar, click it, and uncheck the row again.", expect: "The bar renders visible items in config order; density switches icon-only <-> icon+label (tooltips show titles in icon mode). Home is fixed: checkbox disabled, no drag handle, pinned first - it never moves during a drag preview and a row dragged above it lands right below it, exactly as previewed (no snap-back on drop). The gear popover and the global settings section are THE SAME editor - an edit in one is instantly visible in the other and on the bar. The 3rd density appears in the editor as one more segment and renders icon + full title. The app update appends Help as visible WITHOUT wiping your order/visibility. After F5 everything is restored (memoryGetOrCreate -> memoryCache). onChange fires on every edit with the new config (JSON below); Settings is visible by default, the reset icon is hidden by default, the Reset toolbar row action restores defaults, and that row can hide/show the bar icon.", note: "Three decoupled layers: serializable config (single source of truth, persisted like createUiSlot), Bar, and a PURE Settings editor over config. Density levels live in an extensible module registry (registerToolbarDensity); reorder is a built-in nearest-slot pointer sort (no dnd deps, layout-agnostic: list / bar / grid) + keyboard arrows; the preview simulates the commit incl. fixed pinning, so what you see is what you drop. v1 has no overflow menu - visibility is the space tool.", tall: true, children: _jsx(ToolbarDemo, {}) }), _jsx(Check, { n: 26, title: "useReorder - drag blocks in a field (mini dnd)", do: "Drag blocks around the wrapped field (mouse or touch) - within a row, across rows, to the first/last slot. Click a block without moving. Then enable varied block widths and repeat: rows re-wrap differently, blocks still glide exactly to where they will land.", expect: "During a drag the grabbed block follows the pointer, the rest GLIDE to their preview slots; on drop everything is already where the preview showed - no snap-back, no jumps. A plain click commits nothing (commits counter unchanged). With varied widths the preview is measured from the real CSS layout (FLIP via the order property), so wrapping changes are previewed exactly too.", note: "The library's own mini reorder-by-drag (useReorder, extracted from the Toolbar editor): keyed blocks in ANY CSS layout - list, bar, wrapped grid; DOM order never changes mid-drag, targeting runs against START slots (no boundary oscillation), ONE commit on drop. Deliberately not a dnd framework: no nesting, no cross-container moves, no collision packing.", children: _jsx(ReorderDemo, {}) }), _jsx(Check, { n: 27, title: "useReorderBoard - columns, per-column gravity, cross-column drag", do: "Drag blocks between columns: from a top-packed (up arrow) into a bottom-packed (down arrow) column, into the EMPTY column, back. Watch the landing gap: in a bottom-packed column the blocks ABOVE the slot slide UP to make room. Drag within one column too. Use \u2194 strictly between two columns to insert a new one. The \u2212 directly below a column removes that column; all actions form one aligned lower rail. Click + item in the side tray a few times, drag the created blocks into any column and drag a block back into the tray. On the left rail: + adds a column at the start; drag any block onto the \u2212 trash to delete it. Watch the events line: over changes, column crossings, drop.", expect: "The dragged block follows the pointer; the hovered column highlights (r.over); survivors glide to exactly where they land on drop - including the source column compacting per ITS gravity and the target column opening a real gap per ITS gravity. One commit per drop (counter); a plain click commits nothing. onOverChange fires only when the slot changes, onDragEnd reports the final slot and committed flag. + item spawns E1, E2... into the dashed tray; the tray highlights on hover-over and accepts blocks like any column, and the status line tracks tray:[...]. The \u2212 trash highlights while hovered and swallows the dropped block (last: deleted ...) - it is one more registered column whose content the commit discards.", note: "useReorderBoard - the columns extension of useReorder: column gravity is pure consumer CSS (justify-content), the hook never knows it - it measures the real layout (offset-based FLIP with display:none for the dragged and a real margin gap at the landing slot, so CSS decides who moves aside). Columns register via live callback refs - adding one is just consumer state; the side tray IS one more such column rendered aside, so creating blocks needed no new hook API. Same non-goals: no nesting, no collision packing, no autoscroll.", tall: true, children: _jsx(BoardDemo, {}) }), _jsx(Check, { n: 35, title: "DragBox - imperative delta drag (adapter over useDraggableApi)", do: "Drag the blue chip around (mouse or touch), several gestures in a row. Watch the counters while moving.", expect: "The chip follows the pointer 1:1; releasing commits the position - the next drag continues from where it stopped (no jump-back, no accumulation drift). starts/stops grow by 1 per gesture; renders grows only with starts/stops, NOT per move pixel (the per-tick path is imperative onX/onY over refs).", note: "DragBox is now a thin adapter over useDraggableApi (holdMs 0, trackState:false, onMove) - the old bespoke document-listener loop is gone; contract pinned by __test/dragBox.test.tsx. Production consumer: LeftModal sidebar. DragArea deliberately stays as-is (@deprecated: unique semantics - body listeners, stopPropagation per tick, absolute coords).", children: _jsx(DragBoxDemo, {}) }), _jsx(Check, { n: 31, title: "Toolbar over columnState - one config drives toolbar + menu + grid", do: "Drag the qty column in the GRID before price and HOLD it over the target - toolbar buttons and compact menu must preview the same order BEFORE drop. Open the toolbar gear: drag rows in Settings, toggle checkboxes - the grid and the menu follow. Toggle a button in the compact menu - the toolbar Bar drops/regains the item. Switch density in Settings (Icons / Icons + labels).", expect: "All four surfaces (grid, toolbar Bar, Settings editor, compact menu) mirror ONE config: any reorder or visibility change made on any of them lands on all others. In icon density, items without an icon show their first letters (NAM, QTY, NOT) as a text pseudo-icon; price keeps its emoji. Density and the gear checkbox are toolbar-local (they do not touch the column config); Name is fixed everywhere - not draggable, not hideable.", note: "createToolbar({source}) - the toolbar's order/visibility now can live OUTSIDE it: UiListSource is the extracted control contract, and columnState.api.listSource implements it over the same config the grid adapter syncs. No bridge, no double storage - Toolbar became a VIEW. Backward compatible: without source the toolbar keeps its own store exactly as in card 25.", tall: true, children: _jsx(ToolbarColumnsDemo, {}) }), _jsx(Check, { n: 32, title: "createColumnGrid - default grid menu + mobile dots for table/cards", do: "Switch table/cards. Use the dots overlay: hide/show/replace fields. DRAG a dot slowly along the track and watch the table/cards: every empty mark it crosses swaps the shown column IMMEDIATELY, and a small label above the finger names the column - this is how you search for a column on a phone. Release anywhere.", expect: "createColumnGrid inferred column metadata from columnDefs, applied small overrides, took default data/getId at the controller level, and rendered dots as the built-in overlay with no manual max. Dots are not card-only: the same selector drives the table through columnState.grid.attach - LIVE while dragging (show/hide follows the finger, nothing waits for the drop; the drop only settles selection). Width restore stays protected because Table defaults autoSizeColumns=false; this card explicitly enables fit on visible column-count changes.", note: "This is the reusable wrapper for the card-29/30/31 pattern: one keyed controller, auto ColumnMeta from ag-grid defs, optional overrides/default data, built-in dots overlay, and ready-made representations. Use View for quick table/card switching, or use the returned pieces manually.", tall: true, children: _jsx(ColumnGridKitDemo, {}) }), _jsx(Check, { n: 30, title: "columnState toolbar menu - grouped sub-columns", do: "TOP is our menu drawn with a square-edged client skin over the card-25 toolbar config; the old lower ColumnsMenu is intentionally gone. Drag menu order in Settings and HOLD before drop: the grid and horizontal tiles must preview the same order live, then click column tiles to toggle grid visibility; switch density in Settings and check that labels expand by content. Click the BLO tile with vertical square dots several times. The grid has a Mode block group with 3 sub-columns: Values has text, Zeros has only 0, Empty has blanks. Enable Reset toolbar in Settings and click its tile.", expect: "The grouped block changes by dot count without rebuilding grid columnDefs: 1 dot shows Values+Zeros+Empty; 2 dots shows only Values; 3 dots shows Values+Zeros; 4 dots hides the whole group. The top menu has large square-edged content tiles: 1px separators between tiles, comfortable internal padding, dark by default, white border on hover, white fill when pressed/open. When order/density changes elsewhere, existing tiles animate to their new places; the menu itself remains click-only, without drag handles or drag reorder. Label/full densities grow by text content. BLO uses square vertical dots and remains clickable when the whole group is off. Hidden-by-mode sub-columns keep dashed/inert tiles and revive when the mode brings them back. Reset appears as a small-icon tile only when enabled.", note: "This demonstrates the multi-state layer and replaceable face: the menu uses the standard Toolbar.Bar for structure/order/membership/density while the client fully draws the square-edged item face. The mode tile is not a column; it changes a runtime columnState presentGate over a stable grouped schema. columnState presence marks gated leaf columns disabled; createToolbar({source, sourceMode:'order'}) lets the source own only real-column order, while blockMode position/membership stay local and are never pushed into the grid config.", tall: true, children: _jsx(ColumnsMenuDemo, {}) }), _jsx(Check, { n: 29, title: "columnState mobile - ColumnDots + CardList (dots create the blocks)", do: "Tap an EMPTY mark (qty / ver / note) - a dot appears and the field is created in every card below. Drag a dot slowly along the track - every empty mark it crosses replaces the field LIVE in the cards (a small label above the finger names the current field); release anywhere. Swipe a dot UP (quick vertical flick) - the dot tears off, the field disappears. Tap a dot without moving - the field gets selected (blue); press the sort button several times (asc -> desc -> off). Select ANOTHER dot - note the sort did not change. Enable sort by price, then swipe the price dot away - cards stay ordered by price.", expect: "Dots ARE the visible fields: every dot change instantly rebuilds the cards (no table involved), INCLUDING mid-drag - the swap happens as the dot crosses an empty mark, the drop commits nothing extra. Symbol is fixed (ring): its dot cannot be dragged away or torn off, it is the card title. ver shows as a badge (accent role). The sort is STICKY: it survives selecting other dots AND hiding its own field; the arrow marker above the track shows the sorted column. max=8 here equals the column count, so the dot cap never bites in this demo.", note: "ColumnDots + CardList run on the columnState config alone; this stand uses optional layout=compact (two-column key/value fields, one column below 320px) - no ag-grid, no storage. The same config could drive a desktop grid via grid.attach (card 28). Touch works: gestures are pointer events with a dominant-axis test, so a horizontal drag never removes and a vertical flick never reorders.", tall: true, children: _jsx(MobileColumnsDemo, {}) }), _jsx(Check, { n: 28, title: "columnState - persisted column layout (external layer)", do: "Drag the qty column before price, resize it, hide price via the button, click the qty header to sort. Try dragging a column BEFORE the fixed Name too. Then: unmount -> mount the grid.", expect: "The JSON below mirrors every change (order/visible/width/sort) - the reverse reactivity: whatever the GRID does lands in the config. A column dropped before Name snaps back: Name is fixed and stays first, in the grid AND in the config. After remount the grid restores the exact layout from the in-memory config. sort price cycles asc -> desc -> off and the header arrow follows; sort survives hiding the column (sticky sort).", note: "createColumnState: standalone config store + two-way ag-grid adapter attached via onGridReady - agGrid4 itself is untouched. Name is fixed (fixed: true): it cannot be hidden and always stays first. Storage wiring is deliberately NOT here: what to persist will become a library-level setting later, components stay storage-free.", tall: true, children: _jsx(ColumnStateDemo, {}) }), _jsx(Check, { n: 8, title: "Outside-click closing (OutsideClickArea)", do: "Click open. Then click ANY place outside the panel, including on the same horizontal line and slightly to the right of the open button, within the panel width where there used to be a dead zone.", expect: "A click anywhere outside the panel/button closes it, including the area to the right of the button above the panel. Clicking the panel or button does NOT close it.", note: "Library BUG, this card used to fail on it: Button+outClick wraps in OutsideClickArea, which is a full-width block div, so the entire horizontal strip counts as inside. Here OutsideClickArea uses display:inline-block, and the popup uses position:absolute; otherwise it expands the wrapper rectangle and creates a dead zone to the right of the button. Real library fix: wrap content by default, or let Button narrow the wrapper.", children: _jsx(OutsideDemo, {}) })] }));
1087
+ return (_jsxs(_Fragment, { children: [_jsx(Check, { n: 18, title: "Observe hooks - local store and listen", do: "Click node.at(count) +1, replace status, plain state mutation + flush, emit listen, and replace whole store.", expect: "Leaf node, selection, direct state mutation after flush, add/delete object keys, and listen hooks all rerender. The count key is read through node.at(count), so it does not conflict with node.count().", note: "This isolates the React adapter from transport: no fetch/SSE/RPC involved.", tall: true, children: _jsx(ObserveStoreLocalDemo, {}) }), _jsx(Check, { n: 17, title: "Observe hooks - store mirror over HTTP/SSE", do: "Click server +1, label, add/delete key, deep leaf/deep add/deep delete, local mirror +1000, stop sync, then manual sync.", expect: "Server buttons POST to the Vite QA server, including add/delete object keys and deep mutations. SSE pushes changedPaths; mirror pulls only the intersecting mask when possible. Local mirror edits render immediately and are overwritten by the next server sync.", note: "This checks the React adapter only in wenay-react2: common2 remains React-free; transport policy stays outside the hook.", tall: true, children: _jsx(ObserveStoreMirrorDemo, {}) }), _jsx(Check, { n: 23, title: "Replay hooks - video line, conflation, time travel, freshness", do: "Watch A and B play the same synthetic video. Toggle slow network for B, switch resolution, unmount/remount A. In C drag the slider (playback pauses), then press live. In D: note the renders counter while the line is fresh, check stall producer, wait 2s, uncheck; while stalled press new client (keyframe). In E: switch pull pace (250ms/1s/3s), press pull now.", expect: "A plays smoothly at 10 fps. B on slow network stays CURRENT (bounded latency): frames drop (dropped/coalesced counters grow), the wire buffer never grows past highWater, and each recovery is one coalesced last-frame envelope. Resolution switches on all clients within a frame. Remounting A continues from the kept seq (seq does not reset, frames counter continues from the tail). C seeks to any archived seq via keyframe+tail fold and hands over to live seamlessly. D: renders stays FLAT while frames grow (no per-event re-renders); on stall the STALE badge appears after ~2s and disappears on the first frame after resume; a client mounted during a stall goes STALE within staleMs (this in-proc keyframe is stamped at request time; a tail/keyframe carrying an old producer ts goes stale from the first paint); StrictMode double-effect leaves one watchdog and no badge flicker. E advances ONLY at the pull cadence: frames jumps by ~pace\u00D710fps per pull while pulls grows by one; seq keeps up with head; switching pace keeps the position (no keyframe restart, frames does not re-fold); pull now folds immediately.", note: "All in-proc: the socket transport is already proven in wenay-common2 (replay/video-socket.demo, canvas-socket.test). This card tests the React side: useReplaySubscribe lifecycle (off on unmount, reconnect by since), useReplayHistory scrubber, frames drawn to canvas via ref - bypassing VDOM, stale/staleMs mirroring common2's edge-triggered watchdog into React state, useReplayFrame pull path (timer around remote.frame(), rev2 frame model; policy:'frame'/hint ride ReplaySubscribeOpts but need a server frameLine - the wire test lives in common2 replay/rpc-auto.test.ts). The producer starts on first render and runs until page reload.", tall: true, children: _jsx(ReplayVideoDemo, {}) }), _jsx(Check, { n: 24, title: "Replay hooks - store sync (useStoreReplayMirror)", do: "Watch ticks/price advance. Click server note / add key / delete key. Uncheck sync enabled, mutate the server a few times, recheck. Click restart. Check stall producer, wait 2.5s, then click server note.", expect: "Mirror follows the server store with seq ascending. While sync is disabled the mirror freezes; on re-enable it catches up through the journal tail (seq jumps to head, no full reset flicker). Object key add/delete replicate. restart resubscribes from the kept seq. On stall the stale flag flips true after 2.5s and lastTs freezes; any server mutation (e.g. server note) flips it back to fresh.", note: "exposeStoreReplay/syncStoreReplay in-proc: the remote is the exposed {line, since, keyframe} facade, exactly what createRpcServerAuto would expose over a socket. staleMs rides the same ReplaySubscribeOpts as the video card.", children: _jsx(ReplayStoreDemo, {}) }), _jsx(Check, { n: 33, title: "Replay hooks - per-key feed (useStoreReplayEach)", do: "Watch the table for a few producer ticks. Click server add row, server delete row, server replace ALL, then remount client (fresh keyframe).", expect: "On mount every row appears with cb calls=1 (keyframe expanded per key). Between clicks only the mutated row's cb calls counter grows - the whole dict is never re-delivered per tick. Delete removes the row via (key, undefined). replace ALL swaps the table: removed rows leave, new rows enter with cb calls=1. Remount folds a fresh keyframe (all counters reset to 1); StrictMode double-effect does not double-count.", note: "React counterpart of Observe.syncStoreReplayEach (wenay-common2 1.0.62): internal mirror store + syncStoreReplay + store.each(). The mirror lives in a ref, so in-mount resubscribes reconnect by journal tail on top of kept state; the fold target is a plain Map (grid-api style), not React state. drain:100 coalesces multiple writes to one key into one call per window.", tall: true, children: _jsx(ReplayStoreEachDemo, {}) }), _jsx(Check, { n: 34, title: "Replay hooks - route hand-off (useReplayRouteSubscribe)", do: "Watch one canvas draw the synthetic video. Click switch direct, then switch relay, then fail route. Repeat while the producer is moving.", expect: "The canvas keeps advancing as one logical fold: route switches catch up by seq before the old route closes, so frames do not reset or duplicate. The label changes to direct/relay only after ready. fail route reports an error but keeps the previous active route alive and the canvas continues.", note: "React wrapper over wenay-common2 1.0.65 Replay.replayRouteSubscribe. Route hand-off is explicit through switchRoute(); changing the remote prop remains a fresh subscription boundary. This route helper does not expose stale/lastTs, so freshness stays on the non-route hooks until common2 grows that surface.", tall: true, children: _jsx(ReplayRouteDemo, {}) }), _jsx(Check, { n: 13, title: "ModalProvider / useModal - Escape and outside click", do: "Click open modal. Close it with Escape. Open it again and close with an outside click. Open it again and close with the close button.", expect: "All three methods close it. The dimmed backdrop is above everything (z-index from token --wenay-z-modal).", note: "M1: Escape and closeOnEscape/closeOnOutsideClick options were added; useModal remains the app-level path and createModalElementStore remains low-level.", children: _jsx(ModalDemo, {}) }), _jsx(Check, { n: 20, title: "SettingsDialog - searchable settings tree + registry", do: "Click the three-dot toolbar-style settings button: drag the window by its header, drag the divider between tree and content, double-click it to reset width, use the tree icons and the dotted tree-cycle button, search for suffix/leaf/palette/accent/font/external and wrong-layout examples like \u044B\u0433\u0430\u0430\u0448\u0447 for suffix. Press Enter to save a query into search history, reopen history from the clock button, pick a saved query, then clear history. Clear search via the x and via Escape, then close via window x/outside click/Escape with empty search. Unmount external module and open again.", expect: "The default trigger is the same compact toolbar-button style as createToolbar, using a three-dot icon. Dialog opens as the standard draggable FloatingWindow with a header, larger size, shared close x, and outside-click close. The tree/content divider changes the tree width, persists it through memoryCache, supports keyboard arrows, and double-click/Enter resets to default. Search uses original input plus RU/EN keyboard-layout variants, selects the first real match, auto-expands parents, and highlights only the matched word once. Enter stores non-empty queries in a small persisted search history; choosing a history item restores the query; clearing history removes the dropdown; leaving the search box closes the dropdown. The dotted tree-cycle button switches expanded/current branch/collapsed and stays in the search row. The clear x and Escape both cancel search text; Escape with empty search closes the dialog. The external child under Display appears only while mounted.", note: "Registry is a module singleton (registerSettingsSection -> unregister), no React context. Tree shape comes from children or parentKey. Search history uses createSearchHistory -> memoryGetOrCreate/memoryCache dirty channel; this demo loads memoryCache and saves dirty changes with saveDebounced(300). Look via --dlg-* tokens; apps pass their own section classes via sectionClassName/sectionActiveClassName.", children: _jsx(SettingsDialogDemo, {}) }), _jsx(Check, { n: 38, title: "Media video - capture lifecycle + canvas viewer", do: "Press start camera and grant permission. Move the tab to background for a short time, then return. Press stop, then start again.", expect: "The canvas renders without React re-rendering per frame. State jumps idle \u2192 live (or idle \u2192 denied without a crash; the hook sets state only after start() settles, so no intermediate 'requesting' is shown); the stats line shows drawn frames and frame age. Hidden-tab capture is owned by common2 worker/ImageCapture defaults.", note: "useMediaSource owns only permission/device lifecycle. Media.attachVideoCanvas owns JPEG decode and drawing; frame data never enters React state.", children: _jsx(MediaVideoDemo, {}) }), _jsx(Check, { n: 39, title: "Media audio - mic lifecycle + sequential player", do: "Press enable + start mic, grant permission, speak briefly, then stop. If browser autoplay blocks audio, press the same button again.", expect: "Audio activation happens in a user gesture; common2 player keeps a short live backlog and reports played/dropped frames. React renders only the half-second stats snapshot.", note: "The PCM player is common2 imperative code. This card proves the React wrapper's start/stop cleanup and gesture boundary, not an audio implementation in React.", children: _jsx(MediaAudioDemo, {}) }), _jsx(Check, { n: 40, title: "Peer SDK - mirrored store + explicit resync", do: "Click peer A +1 several times. Watch mirrored value on peer B. Press resync B; the mirror must stay coherent.", expect: "Peer SDK owns relay journal and repair. React reads the peer store normally and exposes route/ready/seq as low-frequency control state; no transport or patch protocol is reimplemented here.", note: "In-process host replaces a fake UI mock: this card proves the actual Peer.createPeerClient contract. Direct WebRTC needs the app's real signaling/rtc factory and remains a separate browser recipe.", children: _jsx(PeerSdkDemo, {}) }), _jsx(Check, { n: 41, title: "Peer calls - ring, accept, hangup", do: "Click call B. On B press accept; both sides become active. Then hang up from A. Repeat and decline on B.", expect: "The incoming ring, active state and terminal reason propagate through the existing Peer signal hub. React only renders manager state; common2 owns call IDs, busy/glare resolution and timeout/offline verdicts.", note: "In-process host, no fake call protocol: usePeerCalls binds Peer.createCallManager. A real app supplies its server-side authorize policy before allowing media viewers.", children: _jsx(PeerCallDemo, {}) }), _jsx(Check, { n: 42, title: "Peer presence - snapshot plus online/offline edges", do: "Observe both accounts online. Toggle B connection off and on.", expect: "The list updates on connection edges without polling. The hook subscribes before it reads the snapshot, so it follows the host protocol rather than creating a second presence store.", note: "Presence is common2 host state. React receives only list/edge data and does not decide authentication or account validity.", children: _jsx(PeerPresenceDemo, {}) }), " ", _jsx(Check, { n: 43, title: "Media relay - actual camera stream with live ACL revoke", do: "Start camera and grant permission: the right canvas must show the relayed stream. Revoke ACL: the viewer frame counter stops while capture keeps running. Grant it again: frames resume. Stop camera.", expect: "The path is camera \u2192 Media source \u2192 common2 relay \u2192 policy-filtered viewer canvas. ACL revocation gates an already-open viewer without trusting React to detach it; the source stats continue because publishing and viewing are separate responsibilities.", note: "This is an in-process relay, so it proves the React/lifecycle seam and live policy filter. A deployed app exposes publishOf/watchOf through its RPC server and keeps canWatch plus call authorization on the server.", children: _jsx(MediaRelayAclDemo, {}) }), " ", _jsx(Check, { n: 44, title: "Audio relay - actual microphone stream with live ACL revoke", do: "Press enable + start relay mic and grant permission, then speak. Revoke ACL: playback and viewer counters stop while capture remains live. Grant it again and confirm playback resumes.", expect: "The path is microphone \u2192 Media source \u2192 common2 audio relay \u2192 policy-filtered AudioContext player. Audio activation is a user gesture; ACL gates the existing viewer without React deciding access.", note: "This uses the relay's short lossless audio queue. In production publishOf/watchOf/canWatch are exposed by the server next to the call authorization policy.", children: _jsx(MediaRelayAudioDemo, {}) }), " ", _jsx(Check, { n: 45, title: "Peer call with live video and audio relay", do: "Enable camera + mic, call B, then accept on B. The canvas starts receiving video only after accept; speak to hear the relayed audio. Hang up: the viewer detaches.", expect: "One real scenario: call state gates server-style relay access and viewer lifecycle. Before/after the active call, capture may run but B receives no media.", note: "This is the complete in-process consumer demo exported from wenay-react2/demo/peer-media; production keeps the same ACL decision on its server.", children: _jsx(PeerCallVideoAudioDemo, {}) }), " ", _jsx(Check, { n: 46, title: "Conference: 3-way star room over the media relay + policy-routed direct focus", tall: true, do: "Ring conf-b and conf-c from the host and accept on both seats: all six grid tiles start moving (each seat watches the other two). Hang up conf-b: only its tiles freeze (live membership ACL); re-ring and re-accept to resume. In the focus panel pick an owner and press go direct: the chip walks relay -> direct:connecting -> direct while the frame counter stays strictly monotonic (no reset, no jump). Press back to relay. Toggle policy: force relay and promote again - denied with reason policy: mustRelay. Untoggle it, toggle server: refuse endpoint exposure and promote - the offer is rejected, the link lands in fallback and frames continue on relay. Clear the toggles, promote, then press server revoke: the live direct session dies server-side and the tile auto-falls back without losing frames. Re-promote and press kill direct transport: the same visible fallback from the transport side. If real WebRTC is unavailable here, keep simulate RTC checked - the loopback runtime negotiates the same signaling.", expect: "The grid is Peer.createMediaRelay fan-out with canWatch reading room membership derived from pairwise host-star calls (group calling is composed, not native: the host holds N-1 concurrent outgoing calls on ONE CallManager). The focus tile is Replay.createRouteCoordinator over ONE owner-sequenced line served by BOTH routes - an in-proc serveReplayChannel relay hop and a WebRTC datachannel via createWebRtcConnector/acceptWebRtcDirect - so every hand-off is gap-free by seq. Client policy hooks and the host authorize gate are separate boundaries and both fail loudly as result objects, never exceptions; server revoke and transport death both auto-fall back to relay.", note: "Frames are JSON snapshots because the replay channel wire is text (connector info binary: false); real-camera binary frames through attachVideoCanvas stay proven by cards 43-45. Exactly one host.connection per account: the signal hub delivers to the LAST registered port, and the call manager, webrtc connector and acceptor deliberately share it. Never route the coordinator through relay.watchOf lines - those are per-watcher re-sequenced journals and a hand-off would silently drop frames. Exported as wenay-react2/demo/peer-conference.", children: _jsx(ConferenceCallDemo, {}) }), " ", _jsx(Check, { n: 21, title: "createUiSlot - configurable block placement", do: "Switch Top bar / Sidebar. Then reload the page (F5).", expect: "The block moves between the two containers WITHOUT a reload; only one mount point shows it at a time. After F5 the chosen place is restored (memoryGetOrCreate -> memoryCache).", note: "Mount points render <Slot place=...> themselves and stay ignorant of each other. The demo calls memoryCache.load() on mount and subscribes memoryCache.onDirty -> saveDebounced(300): the persisted maps are observable and mark memoryCache dirty themselves, the app owns the write policy.", children: _jsx(UiSlotDemo, {}) }), _jsx(Check, { n: 22, title: "createCallbackHub - one slot, many subscribers", do: "Note slot bound = false. Click on A (bound becomes true), fire source event, then on B and fire again. Then off A and fire once more.", expect: "Before the first on() the slot is untouched (lazy bind). With A+B subscribed both receive the same events. After off A, B keeps receiving; hub.count() tracks subscribers.", note: "Fixes the real bug where two onX(cb) subscribers silently overwrote each other. Built on `listen` from wenay-common2; bind(emit) runs once.", children: _jsx(HubDemo, {}) }), _jsx(Check, { n: 25, title: "createToolbar - customizable toolbar (config / Bar / Settings)", do: "Click toolbar items (last action updates). Open the gear popover: toggle Clear workspace on, drag rows to reorder - grab ANYWHERE on the row, mouse or touch, try dragging above the fixed Home too (or focus the handle and press arrow keys), switch density Icons / Icons + labels. Open global settings -> Toolbar section and repeat an edit there. Register the 3rd density and switch to Full text. Uncheck the separated Toolbar settings row at the bottom - the gear (and this popover) disappears from the bar; re-enable it via global settings -> Toolbar. Click simulate app update. Reload the page (F5). In Settings, click the Reset toolbar action button inside its row. Then check the Reset toolbar row, confirm the reset icon appears in the bar, click it, and uncheck the row again.", expect: "The bar renders visible items in config order; density switches icon-only <-> icon+label (tooltips show titles in icon mode). Home is fixed: checkbox disabled, no drag handle, pinned first - it never moves during a drag preview and a row dragged above it lands right below it, exactly as previewed (no snap-back on drop). The gear popover and the global settings section are THE SAME editor - an edit in one is instantly visible in the other and on the bar. The 3rd density appears in the editor as one more segment and renders icon + full title. The app update appends Help as visible WITHOUT wiping your order/visibility. After F5 everything is restored (memoryGetOrCreate -> memoryCache). onChange fires on every edit with the new config (JSON below); Settings is visible by default, the reset icon is hidden by default, the Reset toolbar row action restores defaults, and that row can hide/show the bar icon.", note: "Three decoupled layers: serializable config (single source of truth, persisted like createUiSlot), Bar, and a PURE Settings editor over config. Density levels live in an extensible module registry (registerToolbarDensity); reorder is a built-in nearest-slot pointer sort (no dnd deps, layout-agnostic: list / bar / grid) + keyboard arrows; the preview simulates the commit incl. fixed pinning, so what you see is what you drop. v1 has no overflow menu - visibility is the space tool.", tall: true, children: _jsx(ToolbarDemo, {}) }), _jsx(Check, { n: 26, title: "useReorder - drag blocks in a field (mini dnd)", do: "Drag blocks around the wrapped field (mouse or touch) - within a row, across rows, to the first/last slot. Click a block without moving. Then enable varied block widths and repeat: rows re-wrap differently, blocks still glide exactly to where they will land.", expect: "During a drag the grabbed block follows the pointer, the rest GLIDE to their preview slots; on drop everything is already where the preview showed - no snap-back, no jumps. A plain click commits nothing (commits counter unchanged). With varied widths the preview is measured from the real CSS layout (FLIP via the order property), so wrapping changes are previewed exactly too.", note: "The library's own mini reorder-by-drag (useReorder, extracted from the Toolbar editor): keyed blocks in ANY CSS layout - list, bar, wrapped grid; DOM order never changes mid-drag, targeting runs against START slots (no boundary oscillation), ONE commit on drop. Deliberately not a dnd framework: no nesting, no cross-container moves, no collision packing.", children: _jsx(ReorderDemo, {}) }), _jsx(Check, { n: 27, title: "useReorderBoard - columns, per-column gravity, cross-column drag", do: "Drag blocks between columns: from a top-packed (up arrow) into a bottom-packed (down arrow) column, into the EMPTY column, back. Watch the landing gap: in a bottom-packed column the blocks ABOVE the slot slide UP to make room. Drag within one column too. Use \u2194 strictly between two columns to insert a new one. The \u2212 directly below a column removes that column; all actions form one aligned lower rail. Click + item in the side tray a few times, drag the created blocks into any column and drag a block back into the tray. On the left rail: + adds a column at the start; drag any block onto the \u2212 trash to delete it. Watch the events line: over changes, column crossings, drop.", expect: "The dragged block follows the pointer; the hovered column highlights (r.over); survivors glide to exactly where they land on drop - including the source column compacting per ITS gravity and the target column opening a real gap per ITS gravity. One commit per drop (counter); a plain click commits nothing. onOverChange fires only when the slot changes, onDragEnd reports the final slot and committed flag. + item spawns E1, E2... into the dashed tray; the tray highlights on hover-over and accepts blocks like any column, and the status line tracks tray:[...]. The \u2212 trash highlights while hovered and swallows the dropped block (last: deleted ...) - it is one more registered column whose content the commit discards.", note: "useReorderBoard - the columns extension of useReorder: column gravity is pure consumer CSS (justify-content), the hook never knows it - it measures the real layout (offset-based FLIP with display:none for the dragged and a real margin gap at the landing slot, so CSS decides who moves aside). Columns register via live callback refs - adding one is just consumer state; the side tray IS one more such column rendered aside, so creating blocks needed no new hook API. Same non-goals: no nesting, no collision packing, no autoscroll.", tall: true, children: _jsx(BoardDemo, {}) }), _jsx(Check, { n: 35, title: "DragBox - imperative delta drag (adapter over useDraggableApi)", do: "Drag the blue chip around (mouse or touch), several gestures in a row. Watch the counters while moving.", expect: "The chip follows the pointer 1:1; releasing commits the position - the next drag continues from where it stopped (no jump-back, no accumulation drift). starts/stops grow by 1 per gesture; renders grows only with starts/stops, NOT per move pixel (the per-tick path is imperative onX/onY over refs).", note: "DragBox is now a thin adapter over useDraggableApi (holdMs 0, trackState:false, onMove) - the old bespoke document-listener loop is gone; contract pinned by __test/dragBox.test.tsx. Production consumer: LeftModal sidebar. DragArea deliberately stays as-is (@deprecated: unique semantics - body listeners, stopPropagation per tick, absolute coords).", children: _jsx(DragBoxDemo, {}) }), _jsx(Check, { n: 31, title: "Toolbar over columnState - one config drives toolbar + menu + grid", do: "Drag the qty column in the GRID before price and HOLD it over the target - toolbar buttons and compact menu must preview the same order BEFORE drop. Open the toolbar gear: drag rows in Settings, toggle checkboxes - the grid and the menu follow. Toggle a button in the compact menu - the toolbar Bar drops/regains the item. Switch density in Settings (Icons / Icons + labels).", expect: "All four surfaces (grid, toolbar Bar, Settings editor, compact menu) mirror ONE config: any reorder or visibility change made on any of them lands on all others. In icon density, items without an icon show their first letters (NAM, QTY, NOT) as a text pseudo-icon; price keeps its emoji. Density and the gear checkbox are toolbar-local (they do not touch the column config); Name is fixed everywhere - not draggable, not hideable.", note: "createToolbar({source}) - the toolbar's order/visibility now can live OUTSIDE it: UiListSource is the extracted control contract, and columnState.api.listSource implements it over the same config the grid adapter syncs. No bridge, no double storage - Toolbar became a VIEW. Backward compatible: without source the toolbar keeps its own store exactly as in card 25.", tall: true, children: _jsx(ToolbarColumnsDemo, {}) }), _jsx(Check, { n: 32, title: "createColumnGrid - default grid menu + mobile dots for table/cards", do: "Switch table/cards. Use the dots overlay: hide/show/replace fields. DRAG a dot slowly along the track and watch the table/cards: every empty mark it crosses swaps the shown column IMMEDIATELY, and a small label above the finger names the column - this is how you search for a column on a phone. Release anywhere.", expect: "createColumnGrid inferred column metadata from columnDefs, applied small overrides, took default data/getId at the controller level, and rendered dots as the built-in overlay with no manual max. Dots are not card-only: the same selector drives the table through columnState.grid.attach - LIVE while dragging (show/hide follows the finger, nothing waits for the drop; the drop only settles selection). Width restore stays protected because Table defaults autoSizeColumns=false; this card explicitly enables fit on visible column-count changes.", note: "This is the reusable wrapper for the card-29/30/31 pattern: one keyed controller, auto ColumnMeta from ag-grid defs, optional overrides/default data, built-in dots overlay, and ready-made representations. Use View for quick table/card switching, or use the returned pieces manually.", tall: true, children: _jsx(ColumnGridKitDemo, {}) }), _jsx(Check, { n: 30, title: "columnState toolbar menu - grouped sub-columns", do: "TOP is our menu drawn with a square-edged client skin over the card-25 toolbar config; the old lower ColumnsMenu is intentionally gone. Drag menu order in Settings and HOLD before drop: the grid and horizontal tiles must preview the same order live, then click column tiles to toggle grid visibility; switch density in Settings and check that labels expand by content. Click the BLO tile with vertical square dots several times. The grid has a Mode block group with 3 sub-columns: Values has text, Zeros has only 0, Empty has blanks. Enable Reset toolbar in Settings and click its tile.", expect: "The grouped block changes by dot count without rebuilding grid columnDefs: 1 dot shows Values+Zeros+Empty; 2 dots shows only Values; 3 dots shows Values+Zeros; 4 dots hides the whole group. The top menu has large square-edged content tiles: 1px separators between tiles, comfortable internal padding, dark by default, white border on hover, white fill when pressed/open. When order/density changes elsewhere, existing tiles animate to their new places; the menu itself remains click-only, without drag handles or drag reorder. Label/full densities grow by text content. BLO uses square vertical dots and remains clickable when the whole group is off. Hidden-by-mode sub-columns keep dashed/inert tiles and revive when the mode brings them back. Reset appears as a small-icon tile only when enabled.", note: "This demonstrates the multi-state layer and replaceable face: the menu uses the standard Toolbar.Bar for structure/order/membership/density while the client fully draws the square-edged item face. The mode tile is not a column; it changes a runtime columnState presentGate over a stable grouped schema. columnState presence marks gated leaf columns disabled; createToolbar({source, sourceMode:'order'}) lets the source own only real-column order, while blockMode position/membership stay local and are never pushed into the grid config.", tall: true, children: _jsx(ColumnsMenuDemo, {}) }), _jsx(Check, { n: 29, title: "columnState mobile - ColumnDots + CardList (dots create the blocks)", do: "Tap an EMPTY mark (qty / ver / note) - a dot appears and the field is created in every card below. Drag a dot slowly along the track - every empty mark it crosses replaces the field LIVE in the cards (a small label above the finger names the current field); release anywhere. Swipe a dot UP (quick vertical flick) - the dot tears off, the field disappears. Tap a dot without moving - the field gets selected (blue); press the sort button several times (asc -> desc -> off). Select ANOTHER dot - note the sort did not change. Enable sort by price, then swipe the price dot away - cards stay ordered by price.", expect: "Dots ARE the visible fields: every dot change instantly rebuilds the cards (no table involved), INCLUDING mid-drag - the swap happens as the dot crosses an empty mark, the drop commits nothing extra. Symbol is fixed (ring): its dot cannot be dragged away or torn off, it is the card title. ver shows as a badge (accent role). The sort is STICKY: it survives selecting other dots AND hiding its own field; the arrow marker above the track shows the sorted column. max=8 here equals the column count, so the dot cap never bites in this demo.", note: "ColumnDots + CardList run on the columnState config alone; this stand uses optional layout=compact (two-column key/value fields, one column below 320px) - no ag-grid, no storage. The same config could drive a desktop grid via grid.attach (card 28). Touch works: gestures are pointer events with a dominant-axis test, so a horizontal drag never removes and a vertical flick never reorders.", tall: true, children: _jsx(MobileColumnsDemo, {}) }), _jsx(Check, { n: 28, title: "columnState - persisted column layout (external layer)", do: "Drag the qty column before price, resize it, hide price via the button, click the qty header to sort. Try dragging a column BEFORE the fixed Name too. Then: unmount -> mount the grid.", expect: "The JSON below mirrors every change (order/visible/width/sort) - the reverse reactivity: whatever the GRID does lands in the config. A column dropped before Name snaps back: Name is fixed and stays first, in the grid AND in the config. After remount the grid restores the exact layout from the in-memory config. sort price cycles asc -> desc -> off and the header arrow follows; sort survives hiding the column (sticky sort).", note: "createColumnState: standalone config store + two-way ag-grid adapter attached via onGridReady - agGrid4 itself is untouched. Name is fixed (fixed: true): it cannot be hidden and always stays first. Storage wiring is deliberately NOT here: what to persist will become a library-level setting later, components stay storage-free.", tall: true, children: _jsx(ColumnStateDemo, {}) }), _jsx(Check, { n: 8, title: "Outside-click closing (OutsideClickArea)", do: "Click open. Then click ANY place outside the panel, including on the same horizontal line and slightly to the right of the open button, within the panel width where there used to be a dead zone.", expect: "A click anywhere outside the panel/button closes it, including the area to the right of the button above the panel. Clicking the panel or button does NOT close it.", note: "Library BUG, this card used to fail on it: Button+outClick wraps in OutsideClickArea, which is a full-width block div, so the entire horizontal strip counts as inside. Here OutsideClickArea uses display:inline-block, and the popup uses position:absolute; otherwise it expands the wrapper rectangle and creates a dead zone to the right of the button. Real library fix: wrap content by default, or let Button narrow the wrapper.", children: _jsx(OutsideDemo, {}) })] }));
1088
1088
  }
1089
1089
  function ArchiveChecks() {
1090
1090
  return (_jsxs(_Fragment, { children: [_jsx(Check, { n: 1, title: "Reactivity updateBy / renderBy", do: "Click +1 and renderBy, then +1 WITHOUT renderBy, then renderBy only.", expect: "+1 and renderBy increases the number. +1 WITHOUT renderBy does NOT change the number on screen. renderBy only shows the accumulated value.", note: "This is the split between change and notification. After migration to the store, app.set(...) performs both steps at once.", children: _jsx(ReactivityDemo, {}) }), _jsx(Check, { n: 14, title: "Keyboard API - useKeyboard / keyboard", do: "Press any key, then click reset via API.", expect: "Last key updates through keyboard.on; reset clears the value and also notifies subscribers.", note: "The new pub/sub is built with `listen`: listen.on(cb) -> off(). The old keyboardState remains compatible.", children: _jsx(KeyDownDemo, {}) }), _jsx(Check, { n: 2, title: "Drag + Resize (FloatingWindow / FloatingWindow)", do: "Click window, drag the window by its header, resize it from the edges, and close it with the x button. Open the console (F12).", expect: "The window moves and resizes smoothly; the x button closes it; position and size are restored on reopen (keyForSave).", note: "Historical bug (fixed): the console used to fill with xxx spam from a FloatingWindow debug log; the log is gone, so any console spam during drag/resize is a regression.", tall: true, children: _jsx(Button, { button: (e) => _jsx("div", { style: { display: "inline-block", padding: "6px 12px", border: "1px solid #0969da", borderRadius: 6, cursor: "pointer", background: e ? "#0969da" : "#fff", color: e ? "#fff" : "#0969da" }, children: "window" }), children: (api) => (_jsx(FloatingWindow, { keyForSave: "qa-rnd", size: { height: 220, width: 280 }, className: "fon border fonLight", moveOnlyHeader: true, onClickClose: api.onClose, limit: { y: { min: 0 } }, onUpdate: () => { }, children: _jsx("div", { style: { width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", background: "#eef2f6" }, children: "drag header / resize / close" }) }, "qa-rnd")) }) }), _jsx(Check, { n: 3, title: "Nested menu (Menu) + hover", do: "Hover menu, then move the cursor to the item with \u25B6 and into its submenu.", expect: "The menu opens ONLY when hovering the menu trigger itself, not the full row width.", note: "Library BUG confirmed: HoverButton wraps in a <div> with no width, so it becomes a full-row block, unlike ButtonBase (width:min-content). This board wraps it with width:min-content as a workaround; add width:min-content to HoverButton in the library.", children: _jsx("div", { style: { width: "min-content" }, children: _jsx(HoverButton, { button: () => _jsx("div", { style: { display: "inline-block", padding: "6px 12px", border: "1px solid #888", borderRadius: 6, cursor: "pointer", whiteSpace: "nowrap" }, children: "menu" }), children: _jsx(Menu, { zIndex: 50, coordinate: { x: 0, y: 0 }, data: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wenay-react2",
3
- "version": "1.0.50",
3
+ "version": "1.0.51",
4
4
  "description": "Common react",
5
5
  "strict": true,
6
6
  "main": "dist/index.js",