diff --git a/docs/web-ui.md b/docs/web-ui.md index be864b4b..ee2ce321 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -86,6 +86,20 @@ and, if so, skips the refresh (defers 2s). The operator never has the form yanked out from under them mid-type; the update lands as soon as they blur. +**Atomic section repaint:** every managed-section renderer goes +through `paintAtomic(liveRoot, build)`: the builder appends into +a fresh `DocumentFragment` (off-DOM) and the commit is one +`replaceChildren` call. The naive `root.innerHTML = ''; root.append(...)` +shape was visibly flashing empty on every poll cycle — on async +paths the await yield gave the browser a paint opportunity between +the clear and the re-append, and on complex builds (many `el()` +allocations) layout could escape the per-task budget even on the +synchronous path. The fragment approach keeps the intermediate +empty state invisible. Builders receive the fragment as their +`root`, so existing renderer code carries over unchanged; early +returns inside the builder still commit whatever was appended +before they returned. + **`
` open-state preservation:** any collapsible element tagged with `data-restore-key=""` survives the refresh. `snapshotOpenDetails()` walks managed sections before diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index f8f7c892..b0b3ef54 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -1,18 +1,14 @@ // /index.html entry point: tab renderers + tab routing + refreshState // + notification deltas. Reads /api/state on cold load and after every // async-form submit; live updates run through `applyXxx` mutation -// handlers triggered by the dashboard event stream. +// handlers triggered by the dashboard event stream (subscribed via +// `openStream` from common.js). See docs/web-ui.md::Shape (shared by +// both) for the broader contract. // -// #406 step 1: pure helpers + side panel + OS notifications + path -// linkification moved to `./common.js`. -// #406 step 2: the flow-only IIFEs (operator inbox, inbox-pill, broker -// terminal, @-mention composer) moved to `./flow.js`. /flow.html loads -// `flow.js` as its own bundle entry; this file is loaded only by -// /index.html. -// #406 step 3: file renamed from `app.js` → `tabs.js` since it owns -// the dashboard *tabs* surface only (the FL0W page has its own bundle). -// Live SSE subscription is wired through `openStream` from common.js -// (#406 step 3 hookup; see also #408 for stream-side filtering). +// Pure helpers (DOM, side panel, OS notifications, path linkification) +// live in `./common.js`; the flow-page surface (operator inbox, broker +// terminal, @-mention composer) lives in `./flow.js` — `flow.html` and +// `index.html` each load their own bundle. import { marked } from 'marked'; import { @@ -41,37 +37,16 @@ window.marked = marked; const CTX_WARN_TOKENS = 150_000; // fallback red threshold (≈ 75% of 200k) const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (≈ 50% of 200k) - // Helpers ($, el, esc, form, fmtAgeSecs) moved to ./common.js (#406). - - // #464 — atomic-swap render helper. Each managed section's render - // function used to do `root.innerHTML = ''; root.append(...);` in - // sequence; even though both operations sit in the same JS turn, - // operators could still see a "blink" on every poll cycle because - // (a) on async paths the await yield gave the browser a paint - // opportunity, and (b) complex builds with many `el()` allocations - // can blow the browser's per-task budget enough for layout to flash - // empty before the new children land. - // - // The fix: build the new content off-DOM into a `DocumentFragment`, - // then move it into the live root in a single `replaceChildren` - // call. The browser never sees an intermediate empty state. Builder - // callbacks receive the fragment as their `root` argument, so each - // renderer's existing `root.append(...)` code carries over with - // zero internal changes. Early-return inside the builder is fine — - // the commit still happens with whatever the builder appended. + // Atomic-swap render helper: build into a DocumentFragment off-DOM, + // commit with one `replaceChildren`. See docs/web-ui.md::Atomic + // section repaint for why (no intermediate empty-state flash on + // poll cycles even when the builder allocates a lot of `el()`). function paintAtomic(liveRoot, build) { const buf = document.createDocumentFragment(); build(buf); liveRoot.replaceChildren(buf); } - // Side panel singleton (Panel) moved to ./common.js (#406). - - // Path linkification + file-preview side panel (openFilePanel, - // makePathLink, appendText, appendLinkified) moved to ./common.js (#406). - - // OS notification module (NOTIF) moved to ./common.js (#406). - // Track which items we've already notified about so a re-render // doesn't re-fire for the same row. Keyed by stable ids; reset only // when the page reloads.