wenay-react2 1.0.48 → 1.0.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +14 -14
  2. package/doc/EXAMPLE_USAGE.md +48 -0
  3. package/doc/PROJECT_FUNCTIONALITY.md +9 -0
  4. package/doc/PROJECT_RULES.md +27 -27
  5. package/doc/WENAY_REACT2_RENAMES.md +170 -170
  6. package/doc/changes/v1.0.41.md +35 -35
  7. package/doc/changes/v1.0.42.md +26 -26
  8. package/doc/changes/v1.0.43.md +10 -10
  9. package/doc/changes/v1.0.44.md +14 -14
  10. package/doc/changes/v1.0.45.md +8 -8
  11. package/doc/changes/v1.0.46.md +11 -11
  12. package/doc/changes/v1.0.47.md +9 -9
  13. package/doc/changes/v1.0.48.md +8 -8
  14. package/doc/changes/v1.0.49.md +7 -0
  15. package/doc/changes/v1.0.50.md +7 -0
  16. package/doc/examples/README.md +1 -0
  17. package/doc/examples/conference-client.html +34 -0
  18. package/doc/examples/conference-client.ts +212 -0
  19. package/doc/examples/conference-server.mjs +150 -0
  20. package/doc/examples/peer-call-media.tsx +7 -7
  21. package/doc/examples/stand.tsx +5 -5
  22. package/doc/native.md +37 -37
  23. package/doc/progress/README.md +13 -13
  24. package/doc/progress/architecture-fix-queue.md +74 -74
  25. package/doc/progress/column-state-present-gate.md +28 -28
  26. package/doc/progress/common2-adoption-1.0.73.md +28 -28
  27. package/doc/progress/common2-adoption-1.0.74.md +24 -24
  28. package/doc/progress/hook-controller-opportunities.md +363 -363
  29. package/doc/progress/hook-extraction-audit.md +195 -195
  30. package/doc/progress/public-surface-normalization.md +351 -351
  31. package/doc/progress/qa-stand-walkthrough-2026-07-12.md +62 -62
  32. package/doc/progress/stand-as-examples-audit.md +20 -20
  33. package/doc/progress/style-system-normalization.md +121 -121
  34. package/doc/target/README.md +32 -32
  35. package/doc/target/my.md +11 -1
  36. package/doc/wenay-react2-rare.md +3 -0
  37. package/doc/wenay-react2.md +3 -1
  38. package/lib/common/demo/fakeRtcLoopback.d.ts +17 -0
  39. package/lib/common/demo/fakeRtcLoopback.js +108 -0
  40. package/lib/common/demo/peerConference.d.ts +449 -0
  41. package/lib/common/demo/peerConference.js +372 -0
  42. package/lib/common/src/hooks/index.d.ts +1 -0
  43. package/lib/common/src/hooks/index.js +1 -0
  44. package/lib/common/src/hooks/useReplay.js +6 -2
  45. package/lib/common/src/hooks/useRoute.d.ts +28 -0
  46. package/lib/common/src/hooks/useRoute.js +30 -0
  47. package/lib/common/testUseReact/qa.js +3 -2
  48. package/lib/common/testUseReact/replayVideo.d.ts +5 -0
  49. package/lib/common/testUseReact/replayVideo.js +98 -1
  50. package/lib/style/menuRight.css +19 -19
  51. package/lib/style/style.css +23 -23
  52. package/lib/style/tokens.css +184 -184
  53. package/package.json +54 -53
  54. package/doc/changes/v1.0.35.md +0 -26
  55. package/doc/changes/v1.0.37.md +0 -13
  56. package/doc/changes/v1.0.38.md +0 -48
  57. package/doc/changes/v1.0.39.md +0 -35
  58. package/doc/changes/v1.0.40.md +0 -15
@@ -14,8 +14,9 @@ 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, ReplayStoreDemo, ReplayStoreEachDemo } from "./replayVideo";
17
+ import { ReplayVideoDemo, ReplayRouteDemo, ReplayRpcReconnectDemo, ReplayStoreDemo, ReplayStoreEachDemo } from "./replayVideo";
18
18
  import { PeerCallDemo, PeerPresenceDemo, MediaRelayAclDemo, MediaRelayAudioDemo, PeerCallVideoAudioDemo } from "../demo/peerMedia";
19
+ import { ConferenceCallDemo } from "../demo/peerConference";
19
20
  /* ---------- card wrapper ---------- */
20
21
  const card = { border: "1px solid #d0d7de", borderRadius: 10, margin: "14px 0", background: "#fff", overflow: "hidden", fontFamily: "system-ui, sans-serif" };
21
22
  const head = { display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", background: "#f6f8fa", borderBottom: "1px solid #d0d7de" };
@@ -1083,7 +1084,7 @@ const BoardDemo = () => {
1083
1084
  };
1084
1085
  /* ---------- borad ---------- */
1085
1086
  function ActiveChecks() {
1086
- 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: 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: 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
1088
  }
1088
1089
  function ArchiveChecks() {
1089
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: [
@@ -10,5 +10,10 @@ export declare const videoResolutions: readonly [{
10
10
  }];
11
11
  export declare const ReplayRouteDemo: () => import("react/jsx-runtime").JSX.Element;
12
12
  export declare const ReplayVideoDemo: () => import("react/jsx-runtime").JSX.Element;
13
+ /**
14
+ * Real browser transport regression card. The server endpoint is the Vite-only
15
+ * `qaReplay` RPC facade; this component receives only its stable common2 remote.
16
+ */
17
+ export declare const ReplayRpcReconnectDemo: () => import("react/jsx-runtime").JSX.Element;
13
18
  export declare const ReplayStoreDemo: () => import("react/jsx-runtime").JSX.Element;
14
19
  export declare const ReplayStoreEachDemo: () => import("react/jsx-runtime").JSX.Element;
@@ -8,7 +8,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  * these cards exercise the React lifecycle side.
9
9
  */
10
10
  import React, { StrictMode, useEffect, useMemo, useRef, useState } from "react";
11
- import { Observe, Replay } from "wenay-common2";
11
+ import { createRpcClientHub, Observe, Replay } from "wenay-common2";
12
+ import { io } from "socket.io-client";
12
13
  import { useReplaySubscribe, useReplayRouteSubscribe, useReplayFrame, useReplayHistory, useStoreReplayMirror, useStoreReplayEach, useStoreNode, useStoreKeys } from "../src/hooks";
13
14
  const FPS = 10;
14
15
  export const videoResolutions = [{ w: 160, h: 90 }, { w: 320, h: 180 }, { w: 640, h: 360 }];
@@ -233,6 +234,102 @@ export const ReplayVideoDemo = () => {
233
234
  ? _jsx(LiveClient, { remote: demo.remoteDirect, since: lastSeqA.current, onSeq: seq => { lastSeqA.current = seq; } })
234
235
  : _jsxs("div", { style: { ...canvasStyle, height: 180, display: "flex", alignItems: "center", justifyContent: "center", color: "#8b949e" }, children: ["unmounted \u00B7 kept seq ", lastSeqA.current ?? "-"] })] }), _jsxs("div", { children: [_jsx("div", { style: { fontSize: 13, fontWeight: 700, marginBottom: 4 }, children: "B: slow wire + conflateReplay gate" }), _jsx(LiveClient, { remote: demo.wire.remote }), _jsxs("div", { style: statLine, children: ["wire buffer ", demo.wire.buffered(), " \u00B7 conflating ", String(wireStats.conflating), _jsx("br", {}), "dropped ", wireStats.dropped, " \u00B7 coalesced ", wireStats.coalesced, " \u00B7 recoveries ", wireStats.flushes, " (keyframes ", wireStats.keyframes, ")"] })] }), _jsxs("div", { children: [_jsx("div", { style: { fontSize: 13, fontWeight: 700, marginBottom: 4 }, children: "C: time travel (archive + openHistory)" }), _jsx("canvas", { ref: ttCanvasRef, style: canvasStyle }), _jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center", marginTop: 4 }, children: [_jsx("button", { onClick: () => tt.live ? tt.pause() : tt.play(), children: tt.live ? "⏸ pause" : "▶ live" }), _jsx("input", { type: "range", style: { width: 200 }, min: Math.max(0, tt.head - 550), max: Math.max(0, tt.head), value: tt.seq < 0 ? 0 : tt.seq, onChange: e => tt.seek({ seq: Number(e.target.value) }) }), _jsxs("span", { style: { fontSize: 12, fontFamily: "monospace" }, children: [tt.seq, "/", tt.head, " ", tt.live ? "live" : "paused"] })] })] }), _jsxs("div", { children: [_jsxs("div", { style: { fontSize: 13, fontWeight: 700, marginBottom: 4 }, children: ["D: freshness (staleMs=", STALE_MS, ", StrictMode)", " ", _jsx("button", { onClick: () => setGenD(v => v + 1), children: "new client (keyframe)" })] }), _jsx(StrictMode, { children: _jsx(StaleClient, { remote: demo.remoteDirect, staleMs: STALE_MS }, genD) }), _jsx("div", { style: { fontSize: 12, color: "#57606a", maxWidth: 320, marginTop: 4 }, children: "counters refresh only on fresh\u2194stale flips: a flat \"renders\" while frames grow is the no-per-event-render proof" })] }), _jsxs("div", { children: [_jsx("div", { style: { fontSize: 13, fontWeight: 700, marginBottom: 4 }, children: "E: pull at own pace (useReplayFrame)" }), _jsx(PullClient, { remote: demo.remoteDirect })] })] })] });
235
236
  };
237
+ const RpcReplayConsumer = React.memo(function RpcReplayConsumer(p) {
238
+ p.metrics.current.renders++;
239
+ const sub = useReplaySubscribe(p.remote, value => {
240
+ // This is intentionally an append-only QA probe. It reports duplicates; it never
241
+ // suppresses them, so a green card is evidence from the real transport instead of
242
+ // a React-side dedupe mask.
243
+ p.metrics.current.values.push(value);
244
+ p.metrics.current.latestCallbackRevision = p.callbackRevision;
245
+ }, {
246
+ since: 0,
247
+ policy: "queue",
248
+ onSeq: () => { },
249
+ onError: error => { p.metrics.current.errors.push(String(error)); },
250
+ onStale: () => { },
251
+ hint: { callbackRevision: p.callbackRevision },
252
+ });
253
+ p.metrics.current.seq = sub.seq;
254
+ p.metrics.current.ready = sub.ready;
255
+ return null;
256
+ });
257
+ /**
258
+ * Real browser transport regression card. The server endpoint is the Vite-only
259
+ * `qaReplay` RPC facade; this component receives only its stable common2 remote.
260
+ */
261
+ export const ReplayRpcReconnectDemo = () => {
262
+ const hub = useMemo(() => createRpcClientHub(() => io({ path: "/__qa/replay-rpc", transports: ["websocket"], reconnection: false }), rpc => ({ qaReplay: rpc("qaReplay") })), []);
263
+ const metrics = useRef({ values: [], errors: [], renders: 0, latestCallbackRevision: -1, seq: () => -1, ready: false });
264
+ const firstRemote = useRef(null);
265
+ const [remote, setRemote] = useState(null);
266
+ const [mounted, setMounted] = useState(true);
267
+ const [callbackRevision, setCallbackRevision] = useState(0);
268
+ const [connected, setConnected] = useState(false);
269
+ const [snapshot, setSnapshot] = useState({ produced: 0, head: -1, listeners: 0, producing: false, received: 0, missing: 0, duplicates: 0, ordered: true, renders: 0, seq: -1, ready: false, errors: 0 });
270
+ const refresh = async () => {
271
+ const api = hub.facade.qaReplay?.func;
272
+ let server = { produced: 0, head: -1, listeners: 0, producing: false };
273
+ try {
274
+ if (api)
275
+ server = await api.stats();
276
+ }
277
+ catch { /* offline: retain local transport evidence */ }
278
+ const values = metrics.current.values;
279
+ const seen = new Set(values);
280
+ const missing = Array.from({ length: server.produced }, (_, i) => i + 1).filter(value => !seen.has(value)).length;
281
+ const duplicates = values.length - seen.size;
282
+ setSnapshot({
283
+ ...server,
284
+ received: values.length,
285
+ missing,
286
+ duplicates,
287
+ ordered: values.every((value, index) => index == 0 || values[index - 1] < value),
288
+ renders: metrics.current.renders,
289
+ seq: metrics.current.seq(),
290
+ ready: metrics.current.ready,
291
+ errors: metrics.current.errors.length,
292
+ });
293
+ };
294
+ useEffect(() => {
295
+ let alive = true;
296
+ const offConnect = hub.connectListen(() => { if (alive) {
297
+ setConnected(true);
298
+ void refresh();
299
+ } });
300
+ const offDisconnect = hub.disconnectListen(() => { if (alive) {
301
+ setConnected(false);
302
+ void refresh();
303
+ } });
304
+ void hub.setToken(null).then(clients => {
305
+ if (!alive)
306
+ return;
307
+ const next = clients.qaReplay.func.events;
308
+ firstRemote.current ??= next;
309
+ setRemote(next);
310
+ setConnected(true);
311
+ void refresh();
312
+ }).catch(error => { metrics.current.errors.push(String(error)); void refresh(); });
313
+ return () => {
314
+ alive = false;
315
+ offConnect();
316
+ offDisconnect();
317
+ hub.socket?.disconnect?.();
318
+ };
319
+ // The hub is intentionally one logical transport for the life of this card.
320
+ // eslint-disable-next-line react-hooks/exhaustive-deps
321
+ }, [hub]);
322
+ const api = hub.facade.qaReplay?.func;
323
+ const run = (work) => {
324
+ if (!work)
325
+ return;
326
+ void work().then(() => refresh()).catch(error => { metrics.current.errors.push(String(error)); void refresh(); });
327
+ };
328
+ const green = snapshot.produced > 0 && snapshot.missing == 0 && snapshot.duplicates == 0 && snapshot.ordered && snapshot.seq >= snapshot.head && snapshot.listeners == 1 && snapshot.errors == 0;
329
+ const statusStyle = { padding: "3px 8px", borderRadius: 5, color: "#fff", background: green ? "#2da44e" : "#cf222e", fontWeight: 700, fontSize: 12 };
330
+ return _jsxs("div", { style: { display: "grid", gap: 8 }, children: [_jsx("div", { style: { fontSize: 13, color: "#57606a", maxWidth: 760 }, children: "Keep this tab visible: background-tab throttling delays browser work and is not transport lag. Start the producer, wait for delivery, disconnect while it keeps running, rerender, then reconnect the same socket. No restart, key change, remount, or remote replacement is used for recovery." }), _jsxs("div", { style: { display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }, children: [_jsx("button", { onClick: () => run(api ? () => api.start() : undefined), children: "start producer (20/s)" }), _jsx("button", { onClick: () => run(api ? () => api.stop() : undefined), children: "stop producer" }), _jsx("button", { onClick: () => { hub.socket?.disconnect?.(); void refresh(); }, children: "disconnect transport" }), _jsx("button", { onClick: () => { hub.socket?.connect?.(); }, children: "reconnect transport" }), _jsx("button", { onClick: () => { for (let i = 1; i <= 8; i++)
331
+ window.setTimeout(() => setCallbackRevision(value => value + 1), i * 20); }, children: "parent rerender burst" }), _jsx("button", { onClick: () => run(api ? () => api.burst(5_000) : undefined), children: "burst 5,000" }), _jsx("button", { onClick: () => void refresh(), children: "refresh metrics" }), _jsx("button", { onClick: () => { setMounted(value => !value); window.setTimeout(() => void refresh(), 0); }, children: mounted ? "unmount consumer" : "mount consumer" }), _jsx("span", { style: statusStyle, children: green ? "PASS: exact replay" : "waiting / mismatch" })] }), remote && mounted && _jsx(StrictMode, { children: _jsx(RpcReplayConsumer, { remote: remote, metrics: metrics, callbackRevision: callbackRevision }) }), _jsxs("div", { style: { ...statLine, whiteSpace: "normal" }, children: ["transport ", connected ? "connected" : "disconnected", " \u00B7 stable remote ", String(remote != null && remote === firstRemote.current), " \u00B7 producer ", snapshot.producing ? "running" : "stopped", _jsx("br", {}), "produced ", snapshot.produced, " \u00B7 received ", snapshot.received, " \u00B7 seq ", snapshot.seq, "/", snapshot.head, " \u00B7 missing ", snapshot.missing, " \u00B7 duplicates ", snapshot.duplicates, " \u00B7 ordered ", String(snapshot.ordered), _jsx("br", {}), "consumer renders ", snapshot.renders, " \u00B7 latest callback revision ", metrics.current.latestCallbackRevision, "/", callbackRevision, " \u00B7 server listeners ", snapshot.listeners, " \u00B7 ready ", String(snapshot.ready), " \u00B7 errors ", snapshot.errors] }), _jsx("div", { style: { fontSize: 12, color: "#57606a" }, children: "The 5,000-event burst is lossless (`policy: queue`): use refresh after it drains. The consumer records deliveries in a ref, so delivery itself does not trigger React renders; only lifecycle/UI controls do. Unmount then refresh to confirm server listeners reaches 0." })] });
332
+ };
236
333
  function createStoreReplayDemo() {
237
334
  let stalled = false;
238
335
  const store = Observe.createStore({ ticks: 0, price: 100, note: "start", bag: { a: 1 } });
@@ -43,25 +43,25 @@
43
43
  width: max-content;
44
44
  }
45
45
 
46
- .menu-item {
47
- min-height: 30px;
48
- padding: 10px;
49
- position: relative;
50
- cursor: pointer;
51
- color: var(--menu-item-color, #fff);
52
- border-bottom: var(--menu-border, 1px solid rgba(255, 255, 255, 0.41));
53
- transition: background 0.3s, color 0.3s;
54
- }
55
-
56
- .menu-item:hover,
57
- .menu-item.force-hover {
58
- background: var(--menu-item-hover-bg-color, #fff);
59
- color: var(--menu-item-hover-color, #101010);
60
- }
61
-
62
- .menu-item:active {
63
- background: var(--menu-item-pressed-bg-color, #f2c94c);
64
- color: var(--menu-item-pressed-color, #101010);
46
+ .menu-item {
47
+ min-height: 30px;
48
+ padding: 10px;
49
+ position: relative;
50
+ cursor: pointer;
51
+ color: var(--menu-item-color, #fff);
52
+ border-bottom: var(--menu-border, 1px solid rgba(255, 255, 255, 0.41));
53
+ transition: background 0.3s, color 0.3s;
54
+ }
55
+
56
+ .menu-item:hover,
57
+ .menu-item.force-hover {
58
+ background: var(--menu-item-hover-bg-color, #fff);
59
+ color: var(--menu-item-hover-color, #101010);
60
+ }
61
+
62
+ .menu-item:active {
63
+ background: var(--menu-item-pressed-bg-color, #f2c94c);
64
+ color: var(--menu-item-pressed-color, #101010);
65
65
  }
66
66
 
67
67
  .submenu {
@@ -1058,29 +1058,29 @@ body {
1058
1058
  color: var(--cols-card-accent-color, #0969da);
1059
1059
  white-space: nowrap;
1060
1060
  }
1061
- .wenayCardListFields {
1062
- display: block;
1063
- }
1064
- .wenayCardListItem_compact .wenayCardListFields {
1065
- display: grid;
1066
- grid-template-columns: repeat(2, minmax(0, 1fr));
1067
- gap: 4px 8px;
1068
- }
1069
- .wenayCardListItem_compact .wenayCardListField {
1070
- min-width: 0;
1071
- padding: 2px 6px;
1072
- border-radius: 5px;
1073
- background: var(--cols-card-field-bg, #f6f8fa);
1074
- line-height: 1.45;
1075
- }
1076
- .wenayCardListItem_compact .wenayCardListLabel {
1077
- min-width: 0;
1078
- font-size: 10px;
1079
- text-transform: uppercase;
1080
- letter-spacing: .03em;
1081
- }
1082
- @media (max-width: 320px) {
1083
- .wenayCardListItem_compact .wenayCardListFields { grid-template-columns: 1fr; }
1061
+ .wenayCardListFields {
1062
+ display: block;
1063
+ }
1064
+ .wenayCardListItem_compact .wenayCardListFields {
1065
+ display: grid;
1066
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1067
+ gap: 4px 8px;
1068
+ }
1069
+ .wenayCardListItem_compact .wenayCardListField {
1070
+ min-width: 0;
1071
+ padding: 2px 6px;
1072
+ border-radius: 5px;
1073
+ background: var(--cols-card-field-bg, #f6f8fa);
1074
+ line-height: 1.45;
1075
+ }
1076
+ .wenayCardListItem_compact .wenayCardListLabel {
1077
+ min-width: 0;
1078
+ font-size: 10px;
1079
+ text-transform: uppercase;
1080
+ letter-spacing: .03em;
1081
+ }
1082
+ @media (max-width: 320px) {
1083
+ .wenayCardListItem_compact .wenayCardListFields { grid-template-columns: 1fr; }
1084
1084
  }
1085
1085
  .wenayCardListField {
1086
1086
  display: flex;