← Commit history
README.md+12−2
@@ -498,11 +498,21 @@ node test/ui_test.mjs --keep --plan path/to/plan.json   # also a real plan (copi node test/ui_test.mjs --only "editor: item,text size"   # a subset of flows, for a fast inner loop ``` -A full run takes about 12 minutes (14 with `--plan`). Since 0.57.1 the suite also opens every editor and menu, sweeps the header at 54 widths in every lens (four since 0.58.0), fails on any failed request to the app's own server, and fails when a control marked safe was never clicked. Controls with no test are marked `untested` with a reason and counted in the summary, so the gap is stated and not hidden.+**Several people at once** (0.75.1) has a suite of its own, `test/collab_test.mjs`: real Chrome tabs, each signed in as a different person through a stand-in for Adom sign-in, on one plan shaped like a real launch plan (about 490 rows, 50 sections, 190 dependencies). It plays a meeting: people join one after another, edit their own rows, look at rows, scroll and flip their own filters, all at once. It measures what each tab pays (main-thread time, the longest freeze, forced layouts, memory) and checks that every tab ends up holding exactly what the server holds with no edit lost. It also covers a save seen from every lens, presence, idle cost (critical path included), a 150-save soak for leaks, two people on one row, and a tab that drops off and catches up.++```+node test/collab_test.mjs                      # 6 people, every scenario, about 6 minutes+node test/collab_test.mjs --users 12 --throttle 4   # a bigger meeting on slower laptops+node test/collab_test.mjs --plan my.json --report out.json   # a real plan, every number saved+```++A full run of the browser suite takes about 12 minutes (14 with `--plan`). Since 0.57.1 the suite also opens every editor and menu, sweeps the header at 54 widths in every lens (four since 0.58.0), fails on any failed request to the app's own server, and fails when a control marked safe was never clicked. Controls with no test are marked `untested` with a reason and counted in the summary, so the gap is stated and not hidden.  ## Collaboration -Open the same plan from any number of tabs or people (every reader signs in with Adom). Each tab is live: a save in one lands in the others within a second, merged under whatever that tab has not saved yet, so a refresh never clobbers anybody and two people editing different rows at once both land. The legend shows who else has the plan open. Two people editing the same row within a save of each other still end with that row as the later saver had it (field-level merging is next). Use Export (JSON) to hand a copy around and Import (JSON) to load one; an import replaces the plan and reloads every other open tab.+Open the same plan from any number of tabs or people (every reader signs in with Adom). Each tab is live: a save in one lands in the others within a second, merged under whatever that tab has not saved yet, so a refresh never clobbers anybody and two people editing different rows at once both land. The legend shows who else has the plan open. Two people editing the same row within a save of each other still end with that row as the later saver had it (field-level merging is next).++It stays light with a room full of people (0.75.1). Another person's save costs an open tab about a fifth of a second on the chart on a current laptop, and under a second in any lens; someone clicking around costs the others next to nothing; and an idle tab costs nothing, critical path or not. Through 0.75.0 every save anyone made cost every other open tab 15 to 25 seconds of one CPU core: the arrows were redrawn once per bar, about 480 times, and the rows the sidecar added were placed one full layout at a time. Six people in one meeting froze each other's laptops that way. `test/collab_test.mjs` holds the line. Use Export (JSON) to hand a copy around and Import (JSON) to load one; an import replaces the plan and reloads every other open tab.  ## License 
SKILL.md+2
@@ -879,6 +879,8 @@ is why `project.nextUid` can be higher than any uid you can see.  ## Tests +`node test/collab_test.mjs` (0.75.1) is the many-people suite: real Chrome tabs signed in as different people (a fake Carbon, the server on `--no-loopback`), a generated launch-sized plan, and scenarios for joining, a save seen from every lens, a meeting of everyone editing at once, presence, idle cost, a 150-save soak, conflicts and reconnects. It measures main-thread time, long tasks, forced layouts and memory per tab against budgets (`COLLAB_BUDGET_<NAME>` overrides one, `--throttle 4` slows every CPU), and checks every tab converges on the server's plan with no edit lost. `--users`, `--seconds`, `--only`, `--plan`, `--app <dir>` (measure another build), `--report <json>`.+ `node test/ui_test.mjs` (from the package directory) is the browser suite (since 0.66.0 it waits for the page's `html[data-ready]` marker rather than for the network to go idle, which a live stream never lets it; the `collab` flow drives two tabs on one plan), added in 0.57.0 and built to the app-testing skill. It spawns `server.js --no-auth` on scratch ports from a fixed range, 8960-8979 (`UI_TEST_PORTS=<lo>-<hi>` for another; it fails loudly when none is free and never strays outside, so other tools can safely be given ports beyond it), against a SEEDED synthetic plan in a temp dir (20 sections, 123 rows, 21-Sep-2026 to 14-Jan-2027), pins the page's clock to 29-Sep-2026, and drives Chrome from `~/.cache/puppeteer` through the vendored `test/node_modules/puppeteer-core` (`cd test && npm install` once; it does not ship). It asserts effects: the header readouts against an oracle computed from the plan the SERVER saved after each edit (drag, undo, redo, auto-schedule, table edit, delete, paste, create, start shift), the start date at every grain, text size across a refresh in the live app, in a second tab and in an exported snapshot, collapse all in both lenses and across a refresh, and the wrapped legend with the sticky axis, pinned section header and table header measured at 100% and 150% text and across a resize. A coverage manifest fails on any control nobody declared, on any declared control that never renders, and clicks every safe control. 393px, 1440px and 1920px are rendered with no sideways scroll allowed, and a header sweep (every 40px from 360 to 2000 plus 15 named widths; `SWEEP_STEP=20` for denser) asserts both readouts are whole and every header control is on screen, with and without an appName, at 100% and 150% text. Since 0.58.0 the sweep covers all four lenses, and the viewport matrix renders Analytics and Setup too (Setup with 20 sections, plain and with a rename and a remove form open). The `sections` flow drives the Setup page on a plan with rows in every section and no phase uids: after every add, reorder and remove, after each one's undo and redo, and after a reload, it compares the whole section list and every row's section with `/export/json` and checks the page repainted to match; it also covers the one-undo Save, duplicate names refused, Enter pressed five times making one section, an open Remove form surviving Ctrl+Z, the row chords doing nothing on Setup, the chart axis after adding a section, and rename, reorder and remove in an exported snapshot. Since 0.61.0 it drags a row with real mouse events (row 3 to the top, checked against `/export/json` for the order and every row's section, one `phase.reorder` event, undo and redo), checks that a 2px press is a click, Alt+Up/Down on a focused row, and the click-to-edit name and aside: Enter, a click away, Escape, a duplicate and an empty name refused. The `tags` flow (0.59.0) does the same for section tags on a plan whose last section arrives with a messy tag list: every add, comma-add, remove, rename-everywhere, remove-everywhere and prefix pass is checked against `/export/json` and the page, with its undo and redo, the `phase.tags` changelog lines (one batch, `via: setup`, and the undo and redo lines), a reload, the legend's tag chips and fold, the filter's row counts in the chart and the table, its composition with the section chips and Collapse all, its survival across a reload, a removed tag leaving it, the widths 393 and 1440, and tagging and filtering in an exported snapshot; the coverage gate walks a tagged copy of the seeded plan, with a tag registry. Since 0.61.0 it also drives New tag (and its refusal), a colour from the palette and from the hex box, No colour, the colour on a legend chip, a header chip and a Table pill by computed style on both themes (fill and a 3:1 ink), rename and remove everywhere carrying the registry, the `tag.*` changelog lines, and the registry and an unused tag across a reload. The `row tags` flow (0.60.0) drives every row-tag edit path (the item editor, the table's Tags cell, the New panel and its memory, the batch panel's add and remove, a paste, rename and remove everywhere on Setup) against `/export/json` with each one's undo and redo, checks the `tags` changelog lines and their `via`, the effective-tag filter's row counts in the chart and the table, the Analytics per-tag card against hours worked out by hand from its seed, a cleared data-file tag staying cleared across a reload, the Tags column's sort and text filter, CSV out and back in through the Import menu, no row name cut for its tags at 393 and 1440, and showing, filtering and editing row tags in an exported snapshot. `node test/server_test.mjs` is the server's own suite (ports 8940 up). Ports are checked free before use and the server answering /health must be the one the suite spawned. `--keep` leaves screenshots in `test/out/`; `--plan <file>` also runs the header and legend checks against a real plan, COPIED to a temp dir first. It exits non-zero on any failure.  ## Example
docs/BUILD-NOTES.md+77
@@ -3,6 +3,83 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-30 (0.75.1, many people at once: the freeze, and a suite for it)++Drew: "when we had about 6 people during the meeting all sign in, it lagged everyone's laptops+and bluescreened one person's. Please do an extremely in depth performance sweep on this and+validate the collab features ... Develop a whole test suite around this feature".++Forensics first. The meeting was 2026-10-29 15:00 to 17:00 UTC on 0.74.2: about 17 saves by+four people in two hours, the server never restarted (watchdog log), and nothing changed after+it. So not a save storm and not the server: something every open tab paid.++Measured in a lab (a copy of the live plan, a fake Carbon, the server on --no-loopback so+every tab is a real signed-in person, Chrome DevTools metrics and CPU profiles per tab): on+the live plan, opening took 19 to 32 s and another person's save cost every open tab 15 to 17 s+of one core with ~69,000 forced layouts; 0.75.1 opens it in 1.3 s and takes a save in 0.2 s+with 8. The same comparison through the new suite (six people, its generated launch-sized+plan, this laptop):++| Six people on one plan | 0.75.0 | 0.75.1 |+|---|---|---|+| Opening the plan, each person | 15.7 to 16.9 s, ~66,900 forced layouts | 1.2 to 1.35 s, ~112 |+| Another person's save, per open tab, Gantt | up to 13.7 s | under 0.3 s |+| The same in the Table | up to 4.3 s | under 0.7 s |+| A save showing up in every other tab | up to 14.9 s | under 1.8 s (0.8 s of it is the save delay) |+| CPU per tab through the meeting | 82% mean, 98% worst | 22% mean, 24% worst |+| Longest single freeze | 13.2 s | 0.42 s |+| Idle with the critical path on | 91% | 0.2% |+| A teammate with an old critical-path setting | 236 rows glowing, no way to turn it off | off |++Causes and fixes:++- updateBar ended in drawDeps, and startup, every live merge, Auto-schedule and a group undo+  called it once per bar: the whole arrow layer (every route, every layout read) redrawn ~480+  times. paintBar and paintMilestone now paint without the arrows; updateBars paints all and+  draws once; withDeps defers every redraw inside a merge or startup to one at the end.+- drawDeps read each bar right after writing the previous arrow, a forced layout per read. It+  now measures everything first, builds the arrows off the page and adds them in one go.+- The sidecar's created rows went in one by one at startup, each re-appending all ~500 rows+  and re-reading every header's position (23 s of a 32 s startup). inRowBatch places them all+  and reflows once. reflowRows itself skips the DOM when the panes already show that order, and+  applyOrder does not reflow an unchanged order.+- syncCancelMark rescanned every arrow per row; coalesced to one pass per task.+- Every merge rebuilt the section list (every save carries it), re-derived the headers, reset+  the reader's dimmed sections to visible, and re-filtered. An unchanged list is now left alone,+  and a changed one keeps each section's dimmed state by uid or name.+- Every merge rewrote every row's name markup, so every row needed layout. Writes the page+  already shows are skipped (setHtmlIfChanged and friends), so layout stays local. The axis and+  grid are rebuilt only when the axis changes (they were rebuilt twice per merge).+- A merge rendered the table even when it was not on screen, twice when it was; it now renders+  once, only the lens on screen, and the table patches only the rows whose markup changed+  (keyed by row, with a full render on any doubt). Chart geometry is skipped while another lens+  is up and paid once on the way back.+- The critical path pulsed forever: a drop-shadow filter on every arrow of an arrow layer as+  tall as the plan, plus stroke widths and box shadows, every frame. It is now the static bright+  end of that pulse (what reduced-motion readers already had). And the team, who cannot see the+  Critical button since 0.74.0, no longer keep a mode they saved before it was hidden: for them+  critOn() is false.+- Live pulls are coalesced: one at a time, and saves announced during a pull queue exactly one+  more. /health reports stateRev so a monitor can tell when every tab has caught up.+- Found by the suite, not by the meeting: a live merge copied the stored dates back into an+  open item editor (paintBar calls syncEditorDates for every bar), so a person typing a new end+  date while someone else saved lost it on Apply, with every tab agreeing about the wrong date.+  A date field the person has changed is now theirs until Apply; their own drag still moves it.+- Presence: the legend's chips are redrawn only when who is here changes, each row mark is+  touched only when it changes (data-peer-sig), marks repaint at most four times a second, and+  the label rows lost their background fade, which ran a full restyle of the page for every+  frame of every mark. A tab watching five people click around fast went from 25% of a core+  to under 4%.+- The HTML export finds the startup state fetch by matching that line verbatim; editing it for+  this release silently broke the match, so snapshots asked for a state they did not have (and+  the browser suite hung on its sandboxed snapshot server). The page now skips the fetch itself+  in a snapshot, and the export's pattern no longer depends on the exact line.++Not a leak: 150 merges into one tab, then a forced GC, grow the heap by under half a megabyte,+the DOM by a handful of nodes and the listeners by none.++The suite, test/collab_test.mjs, is described in the README's Tests section.+ ## Build notes, 2026-10-29 (0.75.0, project dates on Setup; the start button is gone)  Drew: "in setup, add a project start/end date field (remove start from the top bar of gantt
gantt.html+312−117
@@ -474,7 +474,10 @@ body.col-resizing { cursor: col-resize; user-select: none; } .label-h-m { height: 24px; border-bottom: 1px solid var(--border); }  /* ── Rows ── */-.label-row { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; height: 34px; display: flex; align-items: center; padding: 0 12px; border-bottom: 1px solid rgba(48,54,61,0.15); font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: default; transition: background 0.15s, opacity 0.15s; }+.label-row { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; height: 34px; display: flex; align-items: center; padding: 0 12px; border-bottom: 1px solid rgba(48,54,61,0.15); font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: default; transition: opacity 0.15s; }+/* No background fade (0.75.1): every mark another person's selection put on a row, and+   took off it, ran it for every frame of 150 ms, a full restyle of a 30,000-node page each+   frame, and two thirds of what a watching tab paid for presence. Hover is instant now. */ .label-row:hover { background: var(--row-hover); } .label-row.group { font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 600; color: var(--text2); background: var(--surface); height: 28px; letter-spacing: 0.02em; } .label-row.ms { color: var(--text2); font-size: 11px; font-style: italic; }@@ -640,53 +643,22 @@ body.col-resizing { cursor: col-resize; user-select: none; }    reading, and the one you are doing wins.    Nothing here touches pointer-events, geometry or layout, so hit-testing is exactly    what it was: a glowing bar is dragged, resized and picked on the same pixels. */-.dep-svg path.crit { stroke: var(--crit); opacity: 1; animation: critStroke 2.6s ease-in-out infinite; }-.dep-svg polygon.crit { fill: var(--crit); opacity: 1; animation: critStroke 2.6s ease-in-out infinite; }-@keyframes critStroke {-  0%, 100% { filter: drop-shadow(0 0 1px var(--crit-halo)); stroke-width: 1.5; }-  50%      { filter: drop-shadow(0 0 4px var(--crit-glow)) drop-shadow(0 0 8px var(--crit-halo)); stroke-width: 2.4; }-}-.bar.crit { animation: critBar 2.6s ease-in-out infinite; }-@keyframes critBar {-  0%, 100% { box-shadow: 0 0 0 1px var(--crit), 0 0 5px 1px var(--crit-halo); }-  50%      { box-shadow: 0 0 0 1.5px var(--crit), 0 0 13px 4px var(--crit-glow); }-}-.ms-diamond.crit-ms { animation: critDiamond 2.6s ease-in-out infinite; }-@keyframes critDiamond {-  0%, 100% { box-shadow: 0 0 0 1px var(--crit), 0 0 5px 1px var(--crit-halo); }-  50%      { box-shadow: 0 0 0 2px var(--crit), 0 0 12px 4px var(--crit-glow); }-}+/* Held, not breathing (0.75.1). Through 0.75.0 every one of these pulsed forever: a+   drop-shadow FILTER on each arrow of an arrow layer as tall as the plan, stroke widths+   and box-shadows, re-rendered every frame. With the path on, an idle tab kept one CPU+   core at 100% for as long as it was open. The treatment is the bright end of that pulse,+   the one reduced-motion readers already had, so the path reads exactly as strongly. */+.dep-svg path.crit { stroke: var(--crit); opacity: 1; stroke-width: 2.2; filter: drop-shadow(0 0 3px var(--crit-glow)); }+.dep-svg polygon.crit { fill: var(--crit); opacity: 1; filter: drop-shadow(0 0 3px var(--crit-glow)); }+.bar.crit { box-shadow: 0 0 0 1.5px var(--crit), 0 0 10px 3px var(--crit-glow); }+.ms-diamond.crit-ms { box-shadow: 0 0 0 2px var(--crit), 0 0 10px 3px var(--crit-glow); } /* The rule and its cap are one milestone in the other two styles, so both carry it. */-.ms-line.crit-ms::before { border-left-color: var(--crit); opacity: 1; animation: critRule 2.6s ease-in-out infinite; }-@keyframes critRule {-  0%, 100% { filter: drop-shadow(0 0 1px var(--crit-halo)); }-  50%      { filter: drop-shadow(0 0 4px var(--crit-glow)); }-}-.ms-line.crit-ms .ms-cap { border-color: var(--crit); color: var(--crit); animation: critCap 2.6s ease-in-out infinite; }-@keyframes critCap {-  0%, 100% { box-shadow: 0 0 4px 0 var(--crit-halo); }-  50%      { box-shadow: 0 0 11px 2px var(--crit-glow); }-}-/* The table's half of the same answer: the row that is on the path, breathing on its-   left edge, where the focused-row marker already lives so the two read as one column-   of signals rather than two competing washes. */-.data-table tbody tr.crit-row td:first-child { animation: critRow 2.6s ease-in-out infinite; }-@keyframes critRow {-  0%, 100% { box-shadow: inset 3px 0 0 var(--crit-halo); }-  50%      { box-shadow: inset 3px 0 0 var(--crit), -1px 0 8px 0 var(--crit-glow); }-}-/* A reader who has asked the system not to animate still gets the signal, held at the-   bright end of the same breath rather than taken away. */-@media (prefers-reduced-motion: reduce) {-  .bar.crit, .ms-diamond.crit-ms, .ms-line.crit-ms::before, .ms-line.crit-ms .ms-cap,-  .dep-svg path.crit, .dep-svg polygon.crit,-  .data-table tbody tr.crit-row td:first-child { animation: none; }-  .bar.crit { box-shadow: 0 0 0 1.5px var(--crit), 0 0 10px 3px var(--crit-glow); }-  .ms-diamond.crit-ms { box-shadow: 0 0 0 2px var(--crit), 0 0 10px 3px var(--crit-glow); }-  .ms-line.crit-ms .ms-cap { box-shadow: 0 0 9px 2px var(--crit-glow); }-  .dep-svg path.crit, .dep-svg polygon.crit { filter: drop-shadow(0 0 3px var(--crit-glow)); }-  .data-table tbody tr.crit-row td:first-child { box-shadow: inset 3px 0 0 var(--crit); }-}+.ms-line.crit-ms::before { border-left-color: var(--crit); opacity: 1; filter: drop-shadow(0 0 3px var(--crit-glow)); }+.ms-line.crit-ms .ms-cap { border-color: var(--crit); color: var(--crit); box-shadow: 0 0 9px 2px var(--crit-glow); }+/* The table's half of the same answer: the row that is on the path, marked on its left+   edge, where the focused-row marker already lives so the two read as one column of+   signals rather than two competing washes. */+.data-table tbody tr.crit-row td:first-child { box-shadow: inset 3px 0 0 var(--crit); } /* A PICKED dependency, in dependency mode only. The whole layer stays    pointer-events:none in every mode (see depEdgeAt: picking an arrow is a hit test    against the drawn route, not a hit target laid over the plan), so nothing here can@@ -2258,7 +2230,9 @@ if (CRIT_MODES.indexOf(prefs.critMode) < 0) prefs.critMode = 'off'; // Is anything lit at all. Everything that used to test the old `critOn` flag asks THIS, // so there is still exactly one answer to "is the glow up" and the mode itself is the // only state there is.-function critOn(){ return prefs.critMode !== 'off'; }+// The team cannot see the Critical button (0.74.0), so a mode saved before it was hidden+// would stay on with no way to turn it off (0.75.1): for them it is off.+function critOn(){ return prefs.critMode !== 'off' && document.documentElement.dataset.role !== 'member'; } // Which halves the current style is showing. Everything that measures or lays out the // dashed rule and its cap asks this rather than testing for 'line', because 'both' // shows them too and a test for one name would have left the caps unplaced there.@@ -3151,12 +3125,39 @@ function orderKeyMap(){ // Re-append every row in items order. The dashed milestone rules are painted // above the rows, so they go back on top afterwards or a reorder buries them. function reflowRows(){-  items.forEach((r,i)=>{r._itemIdx=i;labelsEl.appendChild(r._lDiv);bars.appendChild(r._bDiv);if(r.id&&barEls[r.id])barEls[r.id].idx=i;});-  items.filter(r=>r.type==='milestone'&&r._msLine).forEach(r=>bars.appendChild(r._msLine));+  // When both panes already show the rows in this order (every live merge that only+  // changed fields, which still rebuilds the section list), only the indices are restated:+  // re-appending ~1000 nodes and re-reading every header's position was ~300 ms a merge,+  // twice, for nothing (0.75.1).+  const same=paneOrderMatches();+  items.forEach((r,i)=>{r._itemIdx=i;if(!same){labelsEl.appendChild(r._lDiv);bars.appendChild(r._bDiv);}if(r.id&&barEls[r.id])barEls[r.id].idx=i;});+  if(!same)items.filter(r=>r.type==='milestone'&&r._msLine).forEach(r=>bars.appendChild(r._msLine));   recolorByPhase();+  if(same)return;   keepTailLast();   updatePinnedHeader(); }+function paneOrderMatches(){+  // Rows in items order, and nothing out of place around them: no row after the names'+  // tail room (a new row is appended at the very end), and no bar row after a milestone+  // rule (the rules are kept above the rows).+  let i=0, pastTail=false;+  for(const el of labelsEl.children){+    if(el===labelTail){ pastTail=true; continue; }+    if(!el.classList.contains('label-row'))continue;+    if(pastTail)return false;+    const r=items[i++]; if(!r||r._lDiv!==el)return false;+  }+  if(i!==items.length)return false;+  let j=0, pastRule=false;+  for(const el of bars.children){+    if(el.classList.contains('ms-line')){ pastRule=true; continue; }+    if(!el.classList.contains('bar-row'))continue;+    if(pastRule)return false;+    const r=items[j++]; if(!r||r._bDiv!==el)return false;+  }+  return j===items.length;+} // Bar fill and milestone-rule colour are a PURE FUNCTION of the row's current // phase, nothing else. Before this they were set imperatively at build time and // patched at a handful of phase-change sites, so any path that changed a phase@@ -3188,8 +3189,12 @@ function applyOrder(order){   const next=order.map(k=>byKey.get(k)).filter(r=>r&&!isDerivedGroup(r));   if(next.length!==real.length||new Set(next).size!==real.length)return false;   const hdrs=items.filter(isDerivedGroup);+  const before=items.slice();   items.length=0;items.push(...next,...hdrs);   placeHeaders();+  // The same order the panes already show (every live merge that only changed fields):+  // nothing to re-append, no layout to force (0.75.1).+  if(before.length===items.length&&before.every((r,i)=>r===items[i]))return true;   reflowRows();   drawDeps();   return true;@@ -3276,8 +3281,18 @@ function homeRow(r){     const cur=items.indexOf(r);     if(cur>=0){items.splice(cur,1);items.splice(cur<end?end-1:end,0,r);}   }+  if(rowBatch){ rowBatchDirty=true; return; }   reflowRows(); }+// Many rows placed at once (the saved rows at startup, a live merge's new rows) reflow the+// two panes ONCE at the end (0.75.1). Each one used to re-append all ~500 rows and read+// every header's position, a full layout per row: 23 s of a 32 s startup on a real plan.+let rowBatch=0, rowBatchDirty=false;+function inRowBatch(fn){+  rowBatch++;+  try{ fn(); }+  finally{ rowBatch--; if(!rowBatch&&rowBatchDirty){ rowBatchDirty=false; reflowRows(); } }+}  // A group header is a row like any other, so moving a task to another phase has // to move it under that phase's header. Changing only item.phase would leave it@@ -3769,7 +3784,7 @@ function applySnapshot(sn){     sn.entries.forEach(en=>{const r=items.find(d=>d.id===en.id);if(!r)return;r.start=en.s;r.end=en.e;r.deps=en.deps;r.note=en.note;});     (sn.ms||[]).forEach(m=>{m.r.date=m.date;});     (sn.phases||[]).forEach(x=>{x.p.start=x.start;x.p.end=x.end;});-    rebuildTimeline();Object.keys(barEls).forEach(id=>updateBar(id));+    rebuildTimeline();updateBars();     return inv;   }   const r=items.find(d=>d.id===sn.id);@@ -4264,13 +4279,15 @@ function applyState(state){   // Created rows first: the overrides, the deletions and the order all name   // them, so they have to exist before any of those are applied.   if(Array.isArray(state.added)){-    state.added.forEach(a=>{+    const have=new Set(items.map(r=>r.uid).filter(Boolean));+    inRowBatch(()=>state.added.forEach(a=>{       if(!a||typeof a!=='object')return;-      if(a.uid&&items.some(r=>r.uid===a.uid))return;+      if(a.uid&&have.has(a.uid))return;       const r=Object.assign({},a);+      if(r.uid)have.add(r.uid);       addedItems.push(r);       insertRow(r,true);-    });+    }));   }   const byUid=new Map();   items.forEach(r=>{ if(r.uid)byUid.set(r.uid,r); });@@ -4336,15 +4353,24 @@ function applyState(state){       if(t&&t.length)o.tags=t.slice();       return o;     });-    phases.length=0; phases.push(...rebuilt);-    // phaseVis is one flag per lane; resize it to the rebuilt list, all visible.-    phaseVis.length=0; phases.forEach(()=>phaseVis.push(true));-    // Relabel/rehome the derived headers to the rebuilt list and drop any whose phase-    // is gone — that DOM removal is what clears the phantom empty lane. Safe here:-    // the chart was built in init() (rowsBuilt), and buildDerivedHeaders reuses a-    // header's DOM by phase uid, so nothing already on screen is orphaned by mistake.-    if(rowsBuilt)buildDerivedHeaders();-    phasesRebuilt=true;+    // The same sections the page already has (every live merge of a field edit, since+    // every save carries the whole list): nothing to rebuild (0.75.1). A rebuild here+    // re-derived every header, reflowed both panes and re-filtered on every save anyone+    // made, and reset the reader's own dimmed sections in the legend to visible.+    const phSig=p=>JSON.stringify([p.name,p.start||'',p.end||'',p.uid||'',p.lead||'',p.color||'',p.tags||[]]);+    const unchanged=rebuilt.length===phases.length&&rebuilt.every((p,i)=>phSig(p)===phSig(phases[i]));+    if(!unchanged){+      // A section dimmed in this tab's legend stays dimmed, found by uid, then by name.+      const wasVis=new Map(); phases.forEach((p,i)=>{ wasVis.set('u:'+(p.uid||''),phaseVis[i]); wasVis.set('n:'+p.name,phaseVis[i]); });+      phases.length=0; phases.push(...rebuilt);+      phaseVis.length=0; phases.forEach(p=>{ const v=p.uid&&wasVis.has('u:'+p.uid)?wasVis.get('u:'+p.uid):wasVis.has('n:'+p.name)?wasVis.get('n:'+p.name):true; phaseVis.push(v!==false); });+      // Relabel/rehome the derived headers to the rebuilt list and drop any whose phase+      // is gone — that DOM removal is what clears the phantom empty lane. Safe here:+      // the chart was built in init() (rowsBuilt), and buildDerivedHeaders reuses a+      // header's DOM by phase uid, so nothing already on screen is orphaned by mistake.+      if(rowsBuilt)buildDerivedHeaders();+      phasesRebuilt=true;+    }   } else {     (sph||[]).forEach(sv=>{ const p=sv&&phases[sv.i]; if(p){ if(sv.start)p.start=sv.start; if(sv.end)p.end=sv.end;       if(Array.isArray(sv.tags)){ const t=normalizeTags(sv.tags); if(t.length)p.tags=t; else delete p.tags; } } });@@ -4727,7 +4753,20 @@ function saveToFile(){ // edits are never logged as this tab's. Until field-level ops land (the next checkpoint) // two people editing the SAME row within a save of each other still end with that row // as the later saver had it; everything else is kept on both sides.-async function pullAndMerge(){+// One pull at a time (0.75.1). Every announcement of another person's save started its own+// pull, so a burst of saves ran several pulls and full repaints over each other. Now a save+// announced during a pull queues exactly one more, and every caller's promise settles once+// the page has caught up with all of them.+let pullChain=null, pullAgain=false, pullCount=0, liveMerging=false;+function pullAndMerge(){+  if(pullChain){ pullAgain=true; return pullChain; }+  pullChain=(async()=>{+    try{ do{ pullAgain=false; pullCount++; await pullOnce(); }while(pullAgain); }+    finally{ pullChain=null; }+  })();+  return pullChain;+}+async function pullOnce(){   if(SNAPSHOT)return;   const r=await fetch('state'); if(!r.ok)throw new Error('state '+r.status);   const st=await r.json();@@ -4760,19 +4799,27 @@ async function pullAndMerge(){   // Rows the other side DELETED and this tab has not touched: applyState removes them   // (and removeRow records them as the server's). Rows it created: applyState inserts   // them. Everything else is a field write.+  // Everything below draws the arrows at most once, and the table at most once, when it+  // is done (0.75.1).+  liveMerging=true;+  try{+  withTable(()=>withDeps(()=>{   applyState(st);   items.forEach(r=>{ if(r.id)refreshLabelRow(r); else if(r.type==='milestone')refreshMsChrome(r); });-  Object.keys(barEls).forEach(id=>updateBar(id));+  Object.keys(barEls).forEach(paintBar);   if(tagsReady){ pruneTagFilter(); syncTagList(); tagSig=tagSigNow(); }   rebuildLegend(); applyCollapse();-  rebuildTimeline();+  if(prefs.view==='gantt')rebuildTimeline(); else { timelineStale=true; syncPlanSpan(); }   // Not while a cell is being typed into: the repaint would close that editor under the   // person's hands. The model is current either way, and finishing the cell repaints.-  if(!tblEditing)renderTable();+  // And only the lens on screen: switching to the Table renders it from the model.+  if(!tblEditing&&prefs.view==='table')renderTable();   if(prefs.view==='analytics')renderAnalytics(); else if(prefs.view==='setup')renderSections(true);-  drawDeps(); syncPlanSpan(); syncCollapseAllBtn(); syncPlanCtl(); if(critOn())refreshCritical();+  syncPlanSpan(); syncCollapseAllBtn(); syncPlanCtl(); if(critOn())refreshCritical();+  }));   keepPlace(mark);   if(edSubject)syncEditorDates(edSubject);+  }finally{ liveMerging=false; }   lastSnap=snapSemantic();   if(typeof st.stateRev==='number')baseRev=st.stateRev;   liveMerges++;@@ -4809,7 +4856,7 @@ function startLive(){   es.onmessage=ev=>{     let m; try{ m=JSON.parse(ev.data); }catch(e){ return; }     if(m.type==='hello'){ liveYou=m.you||null; if(typeof m.stateRev==='number'&&m.stateRev>(baseRev||0)&&!saveInFlight)pullAndMerge().catch(()=>{}); return; }-    if(m.type==='presence'){ livePeople=m.people||[]; liveTabs=m.tabs||0; liveFocus=Array.isArray(m.focus)?m.focus:[]; renderPresence(); paintPeerFocus(); return; }+    if(m.type==='presence'){ livePeople=m.people||[]; liveTabs=m.tabs||0; liveFocus=Array.isArray(m.focus)?m.focus:[]; renderPresence(); schedulePeerPaint(); return; }     if(m.type==='rev'){       if(m.by===CLIENT_ID)return;       if(typeof m.stateRev!=='number'||m.stateRev<=(baseRev||0))return;@@ -4860,9 +4907,25 @@ function peerChipHtml(p){   const inner=p.avatar?`<img src="${esc(p.avatar)}" alt="" onerror="this.remove()">`:'';   return `<span class="peer-chip" style="--peer:${p.color}" title="${esc(p.name)} is on this row">${inner||esc(p.initials||'?')}</span>`; }+// Only what changed is touched (0.75.1). Each focus message used to strip every mark and chip+// from the page and put them all back, which restyled every marked row (and the table) for one+// person moving to another row: a watching tab spent a quarter of a core on it while five+// people clicked around. Each marked element now carries what it shows (data-peer-sig), so+// an unchanged mark is left alone, a replaced element (a re-rendered table row) is marked+// afresh, and only rows whose mark changed are restyled.+const peerMarked=new Set();+// Others' marks repaint at most four times a second, the last state always painted (0.75.1):+// a room of people clicking around sends a dozen announcements a second, and a mark a+// quarter second late tells nobody anything different.+const PEER_PAINT_MS=250;+let peerPaintAt=0, peerPaintTimer=null;+function schedulePeerPaint(){+  if(peerPaintTimer)return;+  const wait=peerPaintAt+PEER_PAINT_MS-Date.now();+  if(wait<=0){ peerPaintAt=Date.now(); paintPeerFocus(); return; }+  peerPaintTimer=setTimeout(()=>{ peerPaintTimer=null; peerPaintAt=Date.now(); paintPeerFocus(); },wait);+} function paintPeerFocus(){-  document.querySelectorAll('.peer-chips').forEach(e=>e.remove());-  document.querySelectorAll('.peer-focus').forEach(e=>{ e.classList.remove('peer-focus'); e.style.removeProperty('--peer'); });   const byUid=new Map();   liveFocus.forEach(f=>{     if(f.cid===CLIENT_ID)return;@@ -4870,21 +4933,45 @@ function paintPeerFocus(){     const p={name:f.name,initials:f.initials,avatar:f.avatar,color:peerColor(f.id||f.name)};     (f.uids||[]).forEach(u=>{ const a=byUid.get(u)||[]; if(!a.some(x=>x.name===p.name))a.push(p); byUid.set(u,a); });   });-  if(!byUid.size)return;-  const rows=new Map(items.filter(r=>!isGroup(r)&&r.uid).map(r=>[r.uid,r]));-  const trs=tableBody?new Map([...tableBody.querySelectorAll('tr[data-key]')].map(t=>[t.dataset.key,t])):new Map();-  byUid.forEach((people,u)=>{-    const r=rows.get(u); if(!r)return;-    const chips='<span class="peer-chips">'+people.slice(0,3).map(peerChipHtml).join('')+'</span>';-    const mark=el=>{ if(!el)return; el.classList.add('peer-focus'); el.style.setProperty('--peer',people[0].color); };-    mark(r._lDiv); mark(r._bDiv);-    if(r._lDiv)r._lDiv.insertAdjacentHTML('beforeend',chips);-    const tr=trs.get(rowKey(r));-    if(tr){ mark(tr); const td=tr.querySelector('td.c-uid'); if(td)td.insertAdjacentHTML('beforeend',chips); }+  const want=new Map();+  if(byUid.size){+    const rows=new Map(items.filter(r=>!isGroup(r)&&r.uid).map(r=>[r.uid,r]));+    // The table only while it is on screen: switching to it renders it, and that paints.+    const trs=tableBody&&prefs.view==='table'?new Map([...tableBody.querySelectorAll('tr[data-key]')].map(t=>[t.dataset.key,t])):null;+    byUid.forEach((people,u)=>{+      const r=rows.get(u); if(!r)return;+      const shown=people.slice(0,3), color=people[0].color;+      const sig=color+'|'+shown.map(p=>p.name+'/'+(p.avatar||p.initials||'')).join(';');+      const chips='<span class="peer-chips">'+shown.map(peerChipHtml).join('')+'</span>';+      if(r._lDiv)want.set(r._lDiv,{sig,color,chips,into:r._lDiv});+      if(r._bDiv)want.set(r._bDiv,{sig,color});+      const tr=trs&&trs.get(rowKey(r));+      if(tr)want.set(tr,{sig,color,chips,into:tr.querySelector('td.c-uid')});+    });+  }+  for(const el of [...peerMarked]){ if(!want.has(el)){ unmarkPeer(el); peerMarked.delete(el); } }+  want.forEach((w,el)=>{+    if(el.dataset.peerSig===w.sig)return;+    unmarkPeer(el);+    el.classList.add('peer-focus'); el.style.setProperty('--peer',w.color);+    if(w.chips&&w.into)w.into.insertAdjacentHTML('beforeend',w.chips);+    el.dataset.peerSig=w.sig;+    peerMarked.add(el);   }); }+function unmarkPeer(el){+  if(!el.dataset.peerSig&&!el.classList.contains('peer-focus'))return;+  el.classList.remove('peer-focus'); el.style.removeProperty('--peer');+  el.querySelectorAll('.peer-chips').forEach(c=>c.remove());+  delete el.dataset.peerSig;+}+// Redrawn only when who is here changes: a focus message carries the same people (0.75.1).+let presenceSig=''; function renderPresence(){   const leg=$('legend'); if(!leg)return;+  const sig=JSON.stringify([liveTabs,livePeople.map(p=>[p.name,p.initials,p.n]),liveYou&&liveYou.name]);+  if(sig===presenceSig&&($('presence')||liveTabs<2||!livePeople.length))return;+  presenceSig=sig;   const old=$('presence'); if(old)old.remove();   if(liveTabs<2||!livePeople.length)return;   const el=document.createElement('span'); el.className='presence'; el.id='presence';@@ -4895,7 +4982,10 @@ function renderPresence(){   leg.appendChild(el); } // For the suite: pause and resume this tab's stream, and read what it knows.-window.__collab={pause:stopLive,resume:startLive,people:()=>livePeople.slice(),merges:()=>liveMerges,dirty:()=>[...dirtyUids],rev:()=>baseRev};+window.__collab={pause:stopLive,resume:startLive,people:()=>livePeople.slice(),merges:()=>liveMerges,pulls:()=>pullCount,dirty:()=>[...dirtyUids],rev:()=>baseRev,+  // For the collaboration suite (0.75.1): the plan as this tab holds it, row by row, to compare with the server's.+  rows:()=>items.filter(r=>r.uid&&!isDerivedGroup(r)).map(r=>({uid:r.uid,name:r.name||'',owner:r.owner||'',status:r.status||'',start:r.start||'',end:r.end||'',date:r.date||'',phase:r.phase,deps:(r.deps||[]).slice().sort(),desc:r.description||''})),+  dates:()=>Object.assign({},planDates), busy:()=>!!pullChain||saveInFlight||!!saveTimer}; // One save landed. Anything the banner was waiting for is now settled, and the queued // changelog lines go out BEHIND the state they describe, never in front of it. function onSaveOk(msg){@@ -5509,7 +5599,13 @@ gridLayer.style.cssText='position:absolute;inset:0;pointer-events:none;';bars.ap }  // Quarter and month header cells, rebuilt whenever START or UNITS changes.+// The axis and the grid are a pure function of these, so an unchanged axis is not rebuilt+// (0.75.1): rebuildTimeline and relayout each rebuilt both, twice per live merge, which+// threw away and re-laid out every header cell and grid line for nothing.+function axisKey(){ return [START&&START.getTime(),UNITS,COL,G(),isoDay(new Date())].join('|'); }+let headersKey='', gridKey=''; function buildTimeHeaders(){+  const k=axisKey(); if(k===headersKey&&qRow.firstChild)return; headersKey=k;   qRow.innerHTML='';mRow.innerHTML='';qs=[];   const g=G();   let cq=null;@@ -5537,6 +5633,7 @@ function buildTimeHeaders(){   } } function buildGridLines(){+  const k=axisKey(); if(k===gridKey&&gridLayer.firstChild)return; gridKey=k;   gridLayer.innerHTML='';gridEls.length=0;   const g=G();   holidayLegendGrain(g==='day');@@ -5718,7 +5815,7 @@ function buildRowDom(r,idx){     lDiv.appendChild(makeEditBtn(r));     lDiv.appendChild(makeLinkBtn(r));     r._nameSpan=lDiv.querySelector('.row-name');-    r._nameSpan.innerHTML=nameInner(r);+    setHtmlIfChanged(r._nameSpan,nameInner(r));     bDiv.className='bar-row ms-row';     const x=toX(r.date);     const hit=document.createElement('div');hit.className='ms-hit';hit.style.left=x+'px';@@ -5741,7 +5838,7 @@ function buildRowDom(r,idx){     lDiv.appendChild(makeEditBtn(r));     lDiv.appendChild(makeLinkBtn(r));     r._nameSpan=lDiv.querySelector('.row-name');-    r._nameSpan.innerHTML=nameInner(r);+    setHtmlIfChanged(r._nameSpan,nameInner(r));     bDiv.className='bar-row';const x1=toX(r.start),x2=toX(r.end),b=document.createElement('div');b.className='bar'+(r.striped?' bar-striped':'');b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';b.style.background=phaseColor(r.phase);b.dataset.title=r.name;b.dataset.dates=fmt(r.start)+' → '+fmt(r.end);b.dataset.body=r.description||'';b.dataset.prov=provLabel(r);b.dataset.tags=ownTags(r).join(', ');b.dataset.id=r.id;     const nt=document.createElement('div');nt.className='bar-note';nt.textContent=r.note||'';b.appendChild(nt);r._noteEl=nt;     b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle left'}));b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle right'}));bDiv.appendChild(b);barEls[r.id]={bar:b,idx,row:bDiv};}@@ -6177,7 +6274,8 @@ function scheduleRelayout(){clearTimeout(rlTimer);rlTimer=setTimeout(()=>{   lastPaneW=w; lastPaneH=h;   // A width change reflows the whole timeline; a height-only change still has to   // resize the tailroom, or scrolling past the last row breaks after a vertical drag.-  if(w!==lastRelayoutW){ lastRelayoutW=w; relayout(); }+  if(w!==lastRelayoutW){ lastRelayoutW=w; if(timelineStale){ timelineStale=false; rebuildTimeline(); } else relayout(); }+  else if(timelineStale){ timelineStale=false; rebuildTimeline(); }   else { sizeTailroom(); }   // One more pass next frame: if the pane was still settling when we measured,   // the tail would otherwise stay stale until the next resize.@@ -6604,14 +6702,29 @@ function paintCancelledDeps(){     el.classList.toggle('cancelled',dead(el.getAttribute('data-from'))||dead(el.getAttribute('data-to')));   }); }+// Many geometry changes at once (a live merge, startup) draw the arrows once, at the end,+// instead of once per change (0.75.1).+let depDefer=0, depRedrawOwed=false;+function withDeps(fn){+  depDefer++;+  try{ return fn(); }+  finally{ depDefer--; if(!depDefer&&depRedrawOwed){ depRedrawOwed=false; drawDeps(); } }+}+// The chart's geometry while another lens is on screen (0.75.1): there is nothing to measure+// there (the panes are hidden), so the work is owed, and the switch back pays it once.+let timelineStale=false; function drawDeps(){-  depSvg.innerHTML='';let maxY=0;+  if(depDefer){ depRedrawOwed=true; return; }+  if(prefs.view!=='gantt'){ timelineStale=true; return; }+  depSvg.innerHTML='';   // The layer lives inside the bars, so bars.scrollHeight below counts the layer's own   // old height: it could grow and never shrink, and after a filter the chart stayed as   // tall as the whole plan while the names shrank to the rows on screen (0.74.1).   depSvg.setAttribute('height','1');-  // One measurement of the bars for the whole layer, not one per arrow.-  const ctx=depCtx();+  // Every measurement first, then every node, built off the page and added at once+  // (0.75.1): the old loop read a bar's position right after writing the previous arrow,+  // which forced a fresh layout per read, hundreds per redraw.+  const ctx=depCtx(), edges=[];   items.forEach(r=>{if(!r.deps||!r.id)return;const tgt=barEls[r.id];if(!tgt)return;     r.deps.forEach(depId=>{const src=barEls[depId];if(!src)return;       const sItem=items[src.idx];@@ -6623,7 +6736,11 @@ function drawDeps(){       // Measure the row rather than assuming 17, which was half of a 34px row and       // put every arrow in the wrong place the moment the text scale moved it.       const sr=sb0(src),tr=sb0(tgt);-      const sb=src.bar,tb=tgt.bar,x1=sb.offsetLeft+sb.offsetWidth,y1=sr.offsetTop+sr.offsetHeight/2,x2=tb.offsetLeft,y2=tr.offsetTop+tr.offsetHeight/2;+      const sb=src.bar,tb=tgt.bar;+      edges.push({depId,to:r.id,sItem,x1:sb.offsetLeft+sb.offsetWidth,y1:sr.offsetTop+sr.offsetHeight/2,x2:tb.offsetLeft,y2:tr.offsetTop+tr.offsetHeight/2});+    });});+  const frag=document.createDocumentFragment(); let maxY=0;+  edges.forEach(({depId,to,sItem,x1,y1,x2,y2})=>{       // Which dependency this route IS, carried on the drawn elements. Attributes       // rather than extra nodes: the layer is redrawn wholesale on every geometry       // change, so a pick has to be re-applied from state afterwards, and one path@@ -6631,11 +6748,12 @@ function drawDeps(){       // The SOURCE row's phase, derived once per edge and put on both halves of it, so       // the line and the head it ends in are one colour and cannot drift apart.       const stroke=depStrokeFor(sItem);-      const p=document.createElementNS('http://www.w3.org/2000/svg','path');p.setAttribute('d',depPath(x1,y1,x2,y2,ctx,{from:depId,to:r.id}));-      p.setAttribute('data-from',depId);p.setAttribute('data-to',r.id);paintDepEdge(p,stroke);depSvg.appendChild(p);+      const p=document.createElementNS('http://www.w3.org/2000/svg','path');p.setAttribute('d',depPath(x1,y1,x2,y2,ctx,{from:depId,to}));+      p.setAttribute('data-from',depId);p.setAttribute('data-to',to);paintDepEdge(p,stroke);frag.appendChild(p);       const a=document.createElementNS('http://www.w3.org/2000/svg','polygon');a.setAttribute('points',`${x2},${y2} ${x2-6},${y2-3} ${x2-6},${y2+3}`);-      a.setAttribute('data-from',depId);a.setAttribute('data-to',r.id);paintDepEdge(a,stroke);depSvg.appendChild(a);-      maxY=Math.max(maxY,y1,y2);});});+      a.setAttribute('data-from',depId);a.setAttribute('data-to',to);paintDepEdge(a,stroke);frag.appendChild(a);+      maxY=Math.max(maxY,y1,y2);});+  depSvg.appendChild(frag);   depSvg.setAttribute('height',Math.max(maxY+40,bars.scrollHeight));   // The redraw threw away the classes, so the pick, the armed state, the critical   // path and the cancelled dim are put back from the state that outlives the DOM.@@ -7445,7 +7563,7 @@ function refreshRowTags(r){   if(!r)return;   const own=ownTags(r).join(', ');   [r._msHit,r._msLine&&r._msLine.querySelector('.ms-cap'),r.id&&barEls[r.id]&&barEls[r.id].bar].forEach(el=>{ if(el)el.dataset.tags=own; });-  if(r._nameSpan)r._nameSpan.innerHTML=nameInner(r);+  if(r._nameSpan)setHtmlIfChanged(r._nameSpan,nameInner(r)); } // Any change to a section's tags lands here: the filter drops a tag the plan no longer // has, and both lenses, the legend and the Setup page repaint from phases[].@@ -7463,7 +7581,7 @@ function afterTagsChange(){   }   syncTagList();   // A tag's colour (0.61.0) is on every row label that carries it as "#tag" text.-  items.forEach(r=>{ if(r._nameSpan&&ownTags(r).length)r._nameSpan.innerHTML=nameInner(r); });+  items.forEach(r=>{ if(r._nameSpan&&ownTags(r).length)setHtmlIfChanged(r._nameSpan,nameInner(r)); });   refreshSections();   applyTagView(); }@@ -8507,7 +8625,8 @@ function openBatchEditor(){     if(totalStartShift===0&&totalEndShift===0){closeEditor();return;}     const ids=tasks.map(r=>r.id);     snapshotMany(ids);-    tasks.forEach(r=>{r.start=addDays(r.start,totalStartShift);r.end=addDays(r.end,totalEndShift);updateBar(r.id);});+    tasks.forEach(r=>{r.start=addDays(r.start,totalStartShift);r.end=addDays(r.end,totalEndShift);paintBar(r.id);});+    drawDeps();     scheduleSave('batch-editor');closeEditor();   });   $('bClose').addEventListener('click',closeEditor);@@ -8597,8 +8716,10 @@ function batchDeleteTitle(total,others){   return 'Delete all '+rows+' selected, the '+others+(others===1?' milestone':' milestones')+' included. Undoable with Ctrl+Z'; } -function updateMilestone(r){-  if(!r||!r._msEl)return;+// One milestone's place and tooltips, then the arrows (a moved diamond moves its arrows).+function updateMilestone(r){ if(paintMilestone(r))drawDeps(); }+function paintMilestone(r){+  if(!r||!r._msEl)return false;   const x=toX(r.date);   r._msEl.style.left=x+'px';   // Diamond style hovers the hit strip and line style hovers the cap, so both@@ -8610,7 +8731,7 @@ function updateMilestone(r){     if(cap){cap.dataset.dates=fmt(r.date);cap.dataset.title=r.name;cap.dataset.body=r.description||'';}   }   syncEditorDates(r);-  drawDeps();+  return true; } function snapshotMs(r){   pushUndo({type:'ms',r,date:r.date,name:r.name,owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,tags:tagsSnap(r)});@@ -8620,8 +8741,8 @@ function snapshotMs(r){ function refreshMsChrome(r){   if(!r)return;   syncCancelMark(r);-  if(r._nameSpan)r._nameSpan.innerHTML=nameInner(r);-  else if(r._lDiv)r._lDiv.textContent=r.name;+  if(r._nameSpan)setHtmlIfChanged(r._nameSpan,nameInner(r));+  else if(r._lDiv&&r._lDiv.textContent!==r.name)r._lDiv.textContent=r.name;   if(r._msHit){r._msHit.dataset.title=r.name;r._msHit.dataset.body=r.description||'';r._msHit.dataset.prov=provLabel(r);r._msHit.dataset.tags=ownTags(r).join(', ');}   if(r._msLine){const cap=r._msLine.querySelector('.ms-cap');     if(cap){cap.textContent=(r.name||'').replace(/^M(\d+):\s*/,'M$1 ');cap.dataset.title=r.name;cap.dataset.body=r.description||'';cap.dataset.prov=provLabel(r);cap.dataset.tags=ownTags(r).join(', ');}}@@ -8631,7 +8752,25 @@ function refreshMsChrome(r){ // restating those too. This used to patch only the geometry and the dates, so a // description restored from the sidecar (or edited in the table) left the bar // hovering the version that was in the data file.-function updateBar(id){const r=items.find(d=>d.id===id),be=barEls[id];if(!r||!be)return;if(r._noteEl)r._noteEl.textContent=r.note||'';const x1=toX(r.start),x2=toX(r.end);be.bar.style.left=x1+'px';be.bar.style.width=Math.max(x2-x1,6)+'px';be.bar.style.background=phaseColor(r.phase);be.bar.dataset.dates=fmt(r.start)+' → '+fmt(r.end);be.bar.dataset.title=r.name;be.bar.dataset.body=r.description||'';be.bar.dataset.prov=provLabel(r);be.bar.dataset.tags=ownTags(r).join(', ');syncEditorDates(r);drawDeps();}+// One bar's position, colour and tooltip. Every arrow is redrawn after it, because a+// moved bar moves the arrows into and out of it.+function updateBar(id){ if(paintBar(id))drawDeps(); }+// The same paint for EVERY bar, and the arrows once at the end (0.75.1). Startup, a live+// merge, Auto-schedule and a group undo called updateBar per bar, so the whole arrow layer+// (every route, every layout read) was redrawn once per bar: about 480 times on a real+// plan, 15 to 25 seconds of one CPU core frozen, in every open tab, for every save anyone+// made. Six people in a meeting froze each other's laptops that way.+function updateBars(){ Object.keys(barEls).forEach(paintBar); drawDeps(); }+function paintBar(id){+  const be=barEls[id]; if(!be)return false;+  const r=(items[be.idx]&&items[be.idx].id===id)?items[be.idx]:items.find(d=>d.id===id); if(!r)return false;+  if(r._noteEl&&r._noteEl.textContent!==(r.note||''))r._noteEl.textContent=r.note||'';+  const x1=toX(r.start),x2=toX(r.end),b=be.bar;+  setStyleIfChanged(b,'left',x1+'px');setStyleIfChanged(b,'width',Math.max(x2-x1,6)+'px');setStyleIfChanged(b,'background',phaseColor(r.phase));+  setDataIfChanged(b,'dates',fmt(r.start)+' → '+fmt(r.end));setDataIfChanged(b,'title',r.name);setDataIfChanged(b,'body',r.description||'');setDataIfChanged(b,'prov',provLabel(r));setDataIfChanged(b,'tags',ownTags(r).join(', '));+  syncEditorDates(r);+  return true;+} // The shape goes with the content: every opener declares which one it wants, so closing // has to leave the element without any of them rather than let the last panel's aspect // or its metrics ride along into the next one.@@ -9977,8 +10116,9 @@ function runAutoSchedule(){     else{m.n.r.start=m.start;m.n.r.end=m.end;}   });   rebuildTimeline();-  Object.keys(barEls).forEach(id=>updateBar(id));-  items.filter(r=>r.type==='milestone').forEach(r=>updateMilestone(r));+  updateBars();+  items.filter(r=>r.type==='milestone').forEach(paintMilestone);+  drawDeps();   refreshChartGeometry();   renderTable();   scheduleSave('auto-schedule');@@ -11011,7 +11151,10 @@ function syncEditorDates(r){   // changes a row already comes through here, whether it is a drag, an undo, the other   // lens's table cell or an inline label edit.   setEdTitle(r);-  const set=(id,v)=>{const el=$(id);if(el&&v&&el.value!==v)el.value=v;};+  // A date the person has typed into is theirs until Apply (0.75.1): a live merge of someone+  // else's save used to copy the stored dates back over it, so Apply saved the old date with+  // the new status. Their own drag still moves it, since that is newer than what they typed.+  const set=(id,v)=>{const el=$(id);if(!el||!v||el.value===v)return;if(liveMerging&&el.dataset.touched)return;el.value=v;};   if(r.type==='milestone'){ set('meDate',r.date); set('imEnd',r.date); return; }   set('deStart',r.start); set('deEnd',r.end);   set('imStart',r.start); set('imEnd',r.end);@@ -11051,6 +11194,11 @@ function edTabStops(){     .filter(el=>!el.disabled && el.tabIndex>=0 && el.offsetParent!==null); } function wireEditorTab(){+  // A date field the person changed is theirs until Apply (see syncEditorDates). Every+  // panel is fresh markup, so the mark starts clear on each open.+  const touch=e=>{ const t=e.target; if(t&&t.matches&&t.matches('input[type="date"]'))t.dataset.touched='1'; };+  editorEl.addEventListener('input',touch,true);+  editorEl.addEventListener('change',touch,true);   editorEl.addEventListener('keydown',e=>{     if(e.key!=='Tab'||e.altKey||e.ctrlKey||e.metaKey)return;     const ae=document.activeElement;@@ -11203,9 +11351,14 @@ function refreshLabelRow(r){   syncCancelMark(r);   if(!r.id)return;   // into the name span only: rewriting the row would take the edit button with it-  if(r._nameSpan)r._nameSpan.innerHTML=nameInner(r);-  else r._lDiv.innerHTML=nameInner(r);-}+  setHtmlIfChanged(r._nameSpan||r._lDiv,nameInner(r));+}+// A write the page already shows is skipped (0.75.1). A live merge restates every row, and+// rewriting all ~500 names dirtied all ~500 rows, so the next layout redid the whole chart+// for one changed cell. Unchanged markup now leaves its row alone, and layout stays local.+function setHtmlIfChanged(el,html){ if(!el||el.__html===html)return; el.innerHTML=html; el.__html=html; }+function setStyleIfChanged(el,prop,v){ if(el.style[prop]!==v)el.style[prop]=v; }+function setDataIfChanged(el,key,v){ if(el.dataset[key]!==v)el.dataset[key]=v; } // Cancelled work stays on the plan and stays readable; it just stops asking for // attention. The mark is a class carried on the row's own elements rather than a // question every reader re-asks, so the chart, the label column and the table all go@@ -11218,8 +11371,15 @@ function syncCancelMark(r){   if(be&&be.bar)be.bar.classList.toggle('cancelled',on);   // The arrows too, since 0.53.0. Nothing about a status change moves a pixel, so   // there is no geometry event to trigger a redraw and the layer would otherwise keep-  // the old brightness until the next resize.-  paintCancelledDeps();+  // the old brightness until the next resize. Coalesced (0.75.1): a bulk refresh of every+  // row used to rescan every arrow once per row.+  paintCancelledDepsSoon();+}+let cancelPaintQueued=false;+function paintCancelledDepsSoon(){+  if(cancelPaintQueued)return;+  cancelPaintQueued=true;+  queueMicrotask(()=>{ cancelPaintQueued=false; paintCancelledDeps(); }); }  function distinctOwners(){@@ -11392,7 +11552,16 @@ function tagsCell(r,ed){     +own.map(t=>`<span class="row-tag"${tagAttrs(t)}>${esc(t)}</span>`).join('')     +sec.map(t=>`<span class="row-tag inh"${tagAttrs(t)}>${esc(t)}</span>`).join('')+'</span></td>'; }+// Several table renders asked for inside one live merge (applyState's filter pass, then the+// merge itself) make one, at the end (0.75.1).+let tableDefer=0, tableRenderOwed=false;+function withTable(fn){+  tableDefer++;+  try{ return fn(); }+  finally{ tableDefer--; if(!tableDefer&&tableRenderOwed){ tableRenderOwed=false; if(!tblEditing&&prefs.view==='table')renderTable(); } }+} function renderTable(){+  if(tableDefer){ tableRenderOwed=true; return; }   if(!tableBody)return;   // A Tags cell saving chip by chip must not be repainted out from under its own editor;   // finishing the cell renders the table.@@ -11414,8 +11583,32 @@ function renderTable(){       :ownerFilterOn()&&!items.some(r=>!isGroup(r)&&rowShown(r))?'This owner has no rows'+(tagFilterOn()?' with these tags':'')+'. Pick another owner, or All owners.'       :tagFilterOn()&&!items.some(r=>!isGroup(r)&&rowShown(r))?'No row carries these tags, on itself or through its section. Pick other tags in the legend, or clear them.'       :'No items match these filters.';+    tableRowsCache=null;     tableBody.innerHTML='<tr><td colspan="13" class="table-empty">'+why+'</td></tr>';return;}-  tableBody.innerHTML=rows.map(r=>{+  const keys=rows.map(r=>rowKey(r)), html=rows.map(tableRowHtml);+  // Keyed patch (0.75.1). When these are the same rows in the same order as the table+  // already shows (every live merge that changed a field), only the rows whose markup+  // changed are replaced. Rewriting all ~500 rows cost the browser about half a second of+  // style and layout on every save anyone made, for one changed cell.+  const kids=tableBody.children, c=tableRowsCache;+  const same=c&&c.keys.length===keys.length&&kids.length===keys.length&&keys.every((k,i)=>k===c.keys[i]&&kids[i].dataset&&kids[i].dataset.key===k);+  if(same){+    for(let i=0;i<keys.length;i++){+      if(html[i]===c.html[i]&&!tableForceKeys.has(keys[i]))continue;+      const t=document.createElement('template'); t.innerHTML=html[i];+      tableBody.replaceChild(t.content.firstElementChild,kids[i]);+    }+  } else tableBody.innerHTML=html.join('');+  tableForceKeys.clear();+  tableRowsCache={keys,html};+  markFocusedRow();+  paintCritTable();+  paintPeerFocus();+}+let tableRowsCache=null;+// Rows whose DOM was changed by hand (a cell editor), re-rendered whatever their markup says.+const tableForceKeys=new Set();+function tableRowHtml(r){     const ms=isMs(r), editable=ms||!!r.id;     const o=ownerOf(r), st=statusOf(r), dl=deadlineOf(r), sd=ms?'':(r.start||'');     const dot=r.phase!==undefined?`<span class="phase-dot" style="background:${esc(phaseColor(r.phase))}"></span>`:'';@@ -11441,10 +11634,6 @@ function renderTable(){           :`<td class="c-prov${ed}" data-field="prov">${provCell(r)}</td>`)       +`<td class="c-del">${editable?`<button class="row-del" type="button" tabindex="-1" title="Delete this row from the plan">&times;</button>`:''}</td>`       +'</tr>';-  }).join('');-  markFocusedRow();-  paintCritTable();-  paintPeerFocus(); }  // ── Drag a lane ──@@ -12432,6 +12621,7 @@ function startCellEdit(td,caret){   let done=false;   const finish=save=>{     if(done)return;done=true;tblEditing=null;+    tableForceKeys.add(rowKey(r));     closeCellList(); closeOwnerCombo();     // Blur before the re-render so focus lands on the document rather than being     // orphaned on a node that is about to be thrown away.@@ -13105,6 +13295,8 @@ function applyRole(){       $('previewBack').addEventListener('click',()=>{ try{ sessionStorage.removeItem(PREVIEW_KEY); }catch(e){} applyRole(); flash(saveInd,'back to your own view'); }); }   } else if(bar) bar.remove();   if(member&&prefs.view==='analytics'){ prefs.view='gantt'; applyView(); applyFilter(); }+  document.documentElement.setAttribute('data-crit',critOn()?prefs.critMode:'off');+  if(typeof refreshCritical==='function')refreshCritical();   renderDatesCard(); } async function loadRole(){@@ -13223,7 +13415,10 @@ async function startApp(){ $('viewToggle').addEventListener('click',e=>{const b=e.target.closest('.vt-btn');if(b)setView(b.dataset.view);});  // ── Init ──-try{const r=await fetch('state');if(r.ok){const st=await r.json();if(!st.empty){baseRev=typeof st.stateRev==='number'?st.stateRev:0;applyState(st);Object.keys(barEls).forEach(id=>updateBar(id));}else baseRev=0;}}catch(e){}+// A snapshot carries its state already merged into its data, and has no server to ask+// (0.75.1: guarded here, not only by the export's rewrite of this line, which an edit to the+// line silently stopped matching, so snapshots fetched a state that was not there).+if(!SNAPSHOT)try{const r=await fetch('state');if(r.ok){const st=await r.json();if(!st.empty){baseRev=typeof st.stateRev==='number'?st.stateRev:0;withDeps(()=>{applyState(st);updateBars();});}else baseRev=0;}}catch(e){} // The sidecar can carry a status or a description that the chart's own label rows // and tooltips were built from the data file with, so restate them. items.forEach(r=>{ if(r.id)refreshLabelRow(r); else if(r.type==='milestone')refreshMsChrome(r); });
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.75.0",+  "version": "0.75.1",   "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",   "main": "server.js",   "bin": {
page.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.75.0",+  "version": "0.75.1",   "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",   "main": "server.js",   "bin": {
server.js+7−2
@@ -2606,9 +2606,11 @@ function generateHTMLSnapshot(user, dataArg) {   );    // Replace the state fetch block+  // The page also skips this fetch itself when SNAPSHOT is true (0.75.1), so a change to the+  // line that stops this match can no longer make a snapshot ask for a state it does not have.   html = html.replace(-    /try\{const r=await fetch\('state'\);if\(r\.ok\)\{const st=await r\.json\(\);if\(!st\.empty\)\{baseRev=typeof st\.stateRev==='number'\?st\.stateRev:0;applyState\(st\);Object\.keys\(barEls\)\.forEach\(id=>updateBar\(id\)\);\}else baseRev=0;\}\}catch\(e\)\{\}/,-    '/* state already merged into inline data */'+    /try\{const r=await fetch\('state'\);if\(r\.ok\)\{const st=await r\.json\(\);if\(!st\.empty\)\{[^}]*?applyState\(st\)[^}]*\}\)?;?\}else baseRev=0;\}\}catch\(e\)\{\}/,+    '{/* state already merged into inline data */}'   );    // saveToFile and the changelog POST both check SNAPSHOT themselves (set below),@@ -2707,6 +2709,9 @@ const server = http.createServer(async (req, res) => {         ok: true,         rev: log.length ? log[log.length - 1].rev : 0,         events: log.length,+        // The plan's own revision (0.75.1): what every tab's saves are checked against, so a+        // monitor (and the collaboration suite) can tell when every tab has caught up.+        stateRev: stateRev(),         integrity: integrityStatus(),       });       return;
test/collab_test.mjsadded+701
@@ -0,0 +1,701 @@+// adom-gantt collaboration and performance suite (0.75.1).+//+// Several people on one live plan at once, the way a team meeting uses it: real Chrome tabs,+// each signed in as a different person (a fake Carbon stands in for Adom sign-in, and the+// server runs with --no-loopback so every tab is a real session), all on one server. It+// measures what each laptop pays (main-thread time, long tasks, forced layouts, memory) and+// checks that every tab ends up showing exactly what the server holds, with no edit lost.+//+// Written after a meeting where six people opening the plan froze each other's laptops: every+// save anyone made cost every other open tab 15 to 25 seconds of one CPU core. The budgets+// below are what keeps that from coming back.+//+//   node test/collab_test.mjs                      all scenarios, 6 people, generated plan+//   node test/collab_test.mjs --users 12           a bigger meeting+//   node test/collab_test.mjs --throttle 4         every tab on a CPU four times slower+//   node test/collab_test.mjs --only meeting,soak  some scenarios (join merge meeting presence+//                                                  idle soak conflict reconnect)+//   node test/collab_test.mjs --plan my.json       a real plan (and its -state.json) instead+//   node test/collab_test.mjs --seconds 120        a longer meeting+//   node test/collab_test.mjs --app ../old-build   measure another build (checks that need+//                                                  this build's hooks are skipped)+//   node test/collab_test.mjs --report out.json    every number, for comparing runs+//+// Budgets scale with --throttle. COLLAB_BUDGET_<NAME>=<n> overrides one (see BUDGETS).+import puppeteer from './node_modules/puppeteer-core/lib/esm/puppeteer/puppeteer-core.js';+import { spawn } from 'node:child_process';+import { mkdtempSync, writeFileSync, readFileSync, readdirSync, existsSync, copyFileSync, rmSync } from 'node:fs';+import { tmpdir } from 'node:os';+import { join, dirname, basename, resolve } from 'node:path';+import { fileURLToPath } from 'node:url';+import http from 'node:http';+import net from 'node:net';++const HERE = dirname(fileURLToPath(import.meta.url));+const argv = process.argv.slice(2);+const arg = (k, d) => { const i = argv.indexOf('--' + k); return i >= 0 ? argv[i + 1] : d; };+const flag = k => argv.includes('--' + k);+const USERS = Math.max(2, Math.min(12, +arg('users', 6)));+const SECONDS = +arg('seconds', 45);+const THROTTLE = Math.max(1, +arg('throttle', 1));+const ONLY = arg('only', '') ? arg('only').split(',').map(s => s.trim()) : null;+const APP = resolve(arg('app', join(HERE, '..')));+const PLAN = arg('plan', '');+const REPORT = arg('report', '');+const KEEP = flag('keep');+const want = s => !ONLY || ONLY.includes(s);+const sleep = ms => new Promise(r => setTimeout(r, ms));++// ── Budgets: what a tab may spend. Measured on a 2026 laptop at --throttle 1 with six people+// on a 490-row plan, then given room; the build that froze the meeting broke every one of them+// by ten times or more. Each scales with --throttle, and COLLAB_BUDGET_<NAME> overrides it.+// All the tabs share this one machine, and a laptop CPU mixes fast and slow cores, so the+// same work measures up to three times apart from run to run: the budgets are set to catch a+// real regression (the freeze was 25 to 100 times over), not that noise.+const B = (name, v, scales = true) => { const e = process.env['COLLAB_BUDGET_' + name]; return e !== undefined ? +e : scales ? v * THROTTLE : v; };+const BUDGETS = {+  READY_MS: B('READY_MS', 8000),            // a tab, from navigation to ready, with the others open+  STARTUP_LAYOUTS: B('STARTUP_LAYOUTS', 4000, false), // forced layouts while it starts (the freeze had ~70,000)+  MERGE_MS: B('MERGE_MS', 1000),            // main-thread time one other person's save costs a tab+  MERGE_LAYOUTS: B('MERGE_LAYOUTS', 60, false), // forced layouts per merge (the freeze had ~69,000)+  VISIBLE_MS: B('VISIBLE_MS', 3000),        // from a save to every other tab showing it+  MEETING_BUSY: B('MEETING_BUSY', 35, false) * Math.min(THROTTLE, 2), // % of a core, per tab, during the meeting+  LONG_TASK_MS: B('LONG_TASK_MS', 1200),    // the longest single freeze a person may feel+  PRESENCE_BUSY: B('PRESENCE_BUSY', 12, false) * Math.min(THROTTLE, 2), // % a watching tab pays while everyone else clicks around+  CLICK_MS: B('CLICK_MS', 450),             // main-thread time of a click that opens a row's editor+  CLICK_WALL_MS: B('CLICK_WALL_MS', 1200),  // from that click to the editor on screen+  IDLE_BUSY: B('IDLE_BUSY', 2, false),      // % of a core with nothing happening+  CRIT_IDLE_BUSY: B('CRIT_IDLE_BUSY', 5, false), // the same with the critical path on (it was 100%)+  SOAK_HEAP_MB: B('SOAK_HEAP_MB', 8, false),     // growth over the soak, after a forced GC+  SOAK_NODES: B('SOAK_NODES', 2000, false),+  SOAK_LISTENERS: B('SOAK_LISTENERS', 50, false),+  SERVER_P95_MS: B('SERVER_P95_MS', 250, false), // /health latency while the meeting runs+  SERVER_RSS_MB: B('SERVER_RSS_MB', 400, false),+};++// ── Results ──+let pass = 0, fail = 0, skipped = 0, scenario = '';+const failures = [], report = { users: USERS, seconds: SECONDS, throttle: THROTTLE, app: APP, plan: PLAN || 'generated', budgets: BUDGETS, scenarios: {} };+function check(name, ok, detail = '') {+  const tag = scenario ? `[${scenario}] ` : '';+  if (ok) { pass++; console.log(`  ok   ${tag}${name}${detail ? '  (' + detail + ')' : ''}`); }+  else { fail++; failures.push(`${tag}${name} :: ${detail}`); console.log(`  FAIL ${tag}${name}\n       ${detail}`); }+}+function skip(name, why) { skipped++; console.log(`  skip [${scenario}] ${name}: ${why}`); }+const rec = (k, v) => { (report.scenarios[scenario] = report.scenarios[scenario] || {})[k] = v; };+const pct = (num, secs) => +(num / secs * 100).toFixed(1);+const p95 = a => { if (!a.length) return 0; const s = a.slice().sort((x, y) => x - y); return s[Math.min(s.length - 1, Math.floor(s.length * 0.95))]; };+const fmtMs = v => Math.round(v) + ' ms';++// ── A plan shaped like a real launch plan: 50 sections, ~420 rows in the data file, 70 more+// created in the app (the sidecar's `added`, which startup inserts one by one), 5 deleted,+// ~190 dependencies, owners, statuses, notes and tags. Seeded, so every run is the same plan.+function rng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }+const PEOPLE = ['Drew Owens', 'Kaley Mer', 'Noah Ash', 'Colby Knox', 'Adonis Ray', 'Caleb Stone', 'Ana Lopez', 'Bo Chen', 'Cy Diaz', 'Di Evans', 'Ed Fox', 'Flo Gray'];+const STATUSES = ['not-started', 'not-started', 'not-started', 'in-progress', 'in-progress', 'complete', 'blocked', 'cancelled'];+const TAGS = ['Launch', 'Fab', 'Order', 'Assembly', 'Post-process', 'Ops'];+function makePlan() {+  const R = rng(20261029), pick = a => a[Math.floor(R() * a.length)];+  const iso = d => d.toISOString().slice(0, 10), base = Date.UTC(2026, 8, 21);+  const day = n => iso(new Date(base + n * 86400000));+  const phases = [], items = [], added = [];+  let uidN = 0; const uid = () => String(++uidN).padStart(5, '0');+  for (let p = 0; p < 50; p++) phases.push({ name: `Area ${p + 1}`, lead: pick(PEOPLE).split(' ')[0], uid: 'ph' + (p + 1), ...(R() < 0.6 ? { tags: [pick(TAGS)] } : {}) });+  let t = 0;+  const row = (p, inSidecar) => {+    const ms = R() < 0.1, s = Math.floor(R() * 120), len = 2 + Math.floor(R() * 35);+    const o = { type: ms ? 'milestone' : 'task', name: `${ms ? 'Gate' : 'Work'} ${p + 1}.${t + 1}: ${pick(['wire', 'order', 'test', 'build', 'review', 'ship', 'tune', 'clean'])} the ${pick(['panel', 'board', 'cell', 'line', 'oven', 'laser', 'kit', 'doc'])}`,+      phase: p, uid: uid(), owner: R() < 0.25 ? '' : pick(PEOPLE), status: pick(STATUSES) };+    if (ms) o.date = day(s); else { o.id = 't' + (++t); o.start = day(s); o.end = day(s + len); }+    if (R() < 0.5) o.description = `Why this matters for area ${p + 1}, in a sentence or two.`;+    if (R() < 0.2) o.tags = [pick(TAGS)];+    (inSidecar ? added : items).push(o);+    return o;+  };+  for (let p = 0; p < 50; p++) { const n = 7 + Math.floor(R() * 3); for (let i = 0; i < n; i++) row(p, false); }+  for (let i = 0; i < 70; i++) row(Math.floor(R() * 50), true);+  // Dependencies: chains inside sections, and some across them.+  const tasks = [...items, ...added].filter(r => r.id);+  let deps = 0;+  tasks.forEach((r, i) => { const prev = tasks[i - 1]; if (prev && prev.phase === r.phase && R() < 0.45) { (r.deps = r.deps || []).push(prev.id); deps++; } });+  for (let i = 0; i < 30; i++) { const a = pick(tasks), b = pick(tasks); if (a !== b && a.start < b.start && !(b.deps || []).includes(a.id)) { (b.deps = b.deps || []).push(a.id); deps++; } }+  const deleted = items.slice(10, 15).map(r => r.uid);+  const all = [...items, ...added].filter(r => !deleted.includes(r.uid));+  const order = [];+  for (let p = 0; p < 50; p++) all.filter(r => r.phase === p).forEach(r => order.push(r.uid));+  const data = { project: { title: 'Collaboration Test Plan', appName: 'collab-plan', nextUid: uidN + 1, view: { mode: 'gantt', grain: 'week' } }, phases: phases.map(p => Object.assign({ start: '', end: '' }, p)), items };+  const state = { v: 4, items: [], phases: phases.map((p, i) => Object.assign({ i, start: '', end: '', tags: p.tags || [] }, p)), order, deleted, added, stateRev: 87 };+  return { data, state, deps, rows: all.length };+}++// ── A fake Carbon: one account per person, all in the adom org ──+async function fakeCarbon() {+  const people = {}; PEOPLE.forEach((n, i) => { people['u' + i] = { id: 'id' + i, name: 'user' + i, display_name: n, orgs: ['adom'] }; });+  const intents = new Map(); let next = 'u0', seq = 0;+  const srv = http.createServer((req, res) => {+    const send = (c, o) => { res.writeHead(c, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); };+    const who = String(req.headers.authorization || '').replace(/^Bearer S-/, '');+    if (req.method === 'POST' && req.url === '/auth/intents') { const t = 'T' + (++seq); intents.set(t, next); send(200, { token: t, auth_url: 'http://example.invalid/a' }); return; }+    const m = req.url.match(/^\/auth\/intents\/([^/]+)\/status$/);+    if (m) { const w = intents.get(m[1]); send(200, w ? { state: 'authenticated', session_token: 'S-' + w } : { state: 'pending' }); return; }+    const p = people[who];+    if (req.url === '/user' && p) { send(200, { id: p.id, name: p.name, display_name: p.display_name }); return; }+    if (req.url === '/user/memberships' && p) { send(200, p.orgs.map(o => ({ is_owner: false, is_public: true, organization: { id: 'o-' + o, name: o } }))); return; }+    send(401, { error: 'no' });+  });+  const port = await new Promise(r => srv.listen(0, '127.0.0.1', () => r(srv.address().port)));+  return { url: `http://127.0.0.1:${port}`, setNext: w => { next = w; }, close: () => srv.close() };+}++// ── The plan server: sign-in on, loopback NOT trusted, so every tab is a real person ──+const TMP = mkdtempSync(join(tmpdir(), 'adom-gantt-collab-'));+const PORTS = (process.env.COLLAB_TEST_PORTS || '8920-8939').split('-').map(Number);+async function freePort() {+  for (let p = PORTS[0]; p <= PORTS[1]; p++) {+    const ok = await new Promise(r => { const t = net.createServer(); t.once('error', () => r(false)); t.listen(p, '127.0.0.1', () => t.close(() => r(true))); });+    if (ok) return p;+  }+  throw new Error(`no free port in ${PORTS[0]}..${PORTS[1]} (set COLLAB_TEST_PORTS=<lo>-<hi>)`);+}+const servers = [];+async function startServer(carbon) {+  const dir = mkdtempSync(join(TMP, 'plan-'));+  const file = join(dir, 'plan.json');+  if (PLAN) {+    copyFileSync(PLAN, file);+    const side = PLAN.replace(/\.json$/, '-state.json');+    if (existsSync(side)) copyFileSync(side, file.replace(/\.json$/, '-state.json'));+  } else {+    const g = makePlan();+    writeFileSync(file, JSON.stringify(g.data, null, 1));+    writeFileSync(file.replace(/\.json$/, '-state.json'), JSON.stringify(g.state));+  }+  const port = await freePort();+  const proc = spawn(process.execPath, [join(APP, 'server.js'), '--data', file, '--port', String(port), '--no-loopback'],+    { cwd: APP, env: Object.assign({}, process.env, { ADOM_CARBON_API: carbon.url }), stdio: ['ignore', 'pipe', 'pipe'] });+  let log = ''; proc.stdout.on('data', d => log += d); proc.stderr.on('data', d => log += d);+  const base = `http://127.0.0.1:${port}`;+  for (let i = 0; ; i++) {+    if (proc.exitCode !== null) throw new Error('server exited: ' + log);+    try { const r = await fetch(base + '/health'); if (r.ok && /serving .* on http:\S+:\d+/.test(log) && log.includes(':' + port)) break; } catch {}+    if (i > 150) throw new Error('server did not come up: ' + log);+    await sleep(100);+  }+  const token = readFileSync(file.replace(/\.json$/, '-agent-token'), 'utf8').trim();+  const S = { base, dir, file, proc, token, log: () => log };+  // The first person is the plan's owner, so the owner-only paths (critical path, Analytics) run.+  await fetch(base + '/access', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify({ owner: 'user0' }) });+  servers.push(S);+  return S;+}+async function stopServer(S) { const i = servers.indexOf(S); if (i >= 0) servers.splice(i, 1); try { S.proc.kill('SIGTERM'); } catch {} await sleep(100); }+async function signIn(S, carbon, who) {+  carbon.setNext(who);+  const st = await (await fetch(S.base + '/auth/start', { method: 'POST' })).json();+  const r = await fetch(S.base + '/auth/poll?token=' + st.token);+  await r.json();+  const sid = ((r.headers.get('set-cookie') || '').match(/adom_gantt_sid=([^;]+)/) || [])[1];+  if (!sid) throw new Error('sign-in gave no session for ' + who);+  return sid;+}+const asUser = (S, sid, path, opts = {}) => fetch(S.base + path, Object.assign({}, opts, { headers: Object.assign({ Cookie: 'adom_gantt_sid=' + sid }, opts.headers || {}) }));+const serverRev = async S => (await (await fetch(S.base + '/health')).json()).stateRev;+function serverRss(S) { try { const m = readFileSync(`/proc/${S.proc.pid}/status`, 'utf8').match(/VmRSS:\s+(\d+)/); return m ? +m[1] / 1024 : 0; } catch { return 0; } }++// ── Chrome ──+const CHROME_DIR = join(process.env.HOME || '/home/adom', '.cache/puppeteer/chrome');+const CHROME = process.env.CHROME_PATH || (() => {+  const dirs = readdirSync(CHROME_DIR).filter(d => d.startsWith('linux-')).sort((a, b) => a.split('-')[1].localeCompare(b.split('-')[1], undefined, { numeric: true }));+  return join(CHROME_DIR, dirs.at(-1), 'chrome-linux64', 'chrome');+})();+let BROWSER = null;++// One person: their own browser context (cookies, storage, renderer), a CDP session for the+// metrics, the long tasks the page reports, and every request it makes, counted.+async function openPerson(S, carbon, i, { view = 'gantt', w = 1500, h = 900 } = {}) {+  const sid = await signIn(S, carbon, 'u' + i);+  const ctx = await BROWSER.createBrowserContext();+  const page = await ctx.newPage();+  await page.setViewport({ width: w, height: h });+  await page.setCookie({ name: 'adom_gantt_sid', value: sid, url: S.base + '/' });+  await page.evaluateOnNewDocument(v => {+    window.__lt = [];+    try { new PerformanceObserver(l => l.getEntries().forEach(e => window.__lt.push(Math.round(e.duration)))).observe({ type: 'longtask', buffered: true }); } catch (e) {}+    // The lens this person works in, as a preference they had already chosen.+    try { const k = 'adom-gantt:prefs'; const p = JSON.parse(localStorage.getItem(k) || '{}'); p.view = v; localStorage.setItem(k, JSON.stringify(p)); } catch (e) {}+  }, view);+  const cdp = await page.createCDPSession();+  await cdp.send('Performance.enable', { timeDomain: 'timeTicks' });+  if (THROTTLE > 1) await cdp.send('Emulation.setCPUThrottlingRate', { rate: THROTTLE });+  const P = { i, name: PEOPLE[i], sid, ctx, page, cdp, errors: [], req: { save: 0, state: 0, focus: 0, log: 0 }, view };+  page.on('pageerror', e => P.errors.push(e.message));+  page.on('console', m => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) P.errors.push('console: ' + m.text().slice(0, 200)); });+  page.on('request', r => { const u = r.url().replace(/^https?:\/\/[^/]+/, '').split('?')[0], mth = r.method();+    if (mth === 'POST' && u === '/save') P.req.save++; else if (mth === 'GET' && u === '/state') P.req.state++;+    else if (mth === 'POST' && u === '/live/focus') P.req.focus++; else if (mth === 'POST' && u === '/log') P.req.log++; });+  return P;+}+async function goReady(P, S) {+  const t0 = Date.now();+  await P.page.goto(S.base + '/', { waitUntil: 'domcontentloaded', timeout: 120000 });+  await P.page.waitForSelector('html[data-ready="1"]', { timeout: 120000 });+  P.readyMs = Date.now() - t0;+  return P.readyMs;+}+async function closePerson(P) { try { await P.ctx.close(); } catch {} }+async function metrics(P) { const { metrics } = await P.cdp.send('Performance.getMetrics'); const o = {}; metrics.forEach(m => { o[m.name] = m.value; }); return o; }+const busySince = (a, b) => ({ task: b.TaskDuration - a.TaskDuration, layouts: b.LayoutCount - a.LayoutCount, script: b.ScriptDuration - a.ScriptDuration });+const hasHooks = P => P.page.evaluate(() => !!(window.__collab && window.__collab.rows));+const merges = P => P.page.evaluate(() => window.__collab ? window.__collab.merges() : 0);+async function longTasks(P, clear = true) { return P.page.evaluate(c => { const a = (window.__lt || []).slice(); if (c) window.__lt.length = 0; return a; }, clear); }++// ── What a person does, through the page the way a person would ──+// Open a task's editor by clicking its bar (scrolled into view first), the gesture every+// edit starts with; Escape closes it again.+async function clickBar(P, id) {+  const pt = await P.page.evaluate(i => {+    const b = document.querySelector(`.bar[data-id="${i}"]`); if (!b || !b.offsetParent) return null;+    b.scrollIntoView({ block: 'center', inline: 'center' });+    const r = b.getBoundingClientRect(); return { x: r.left + Math.min(r.width / 2, 20), y: r.top + r.height / 2 };+  }, id);+  if (!pt) return false;+  await sleep(60);+  await P.page.mouse.click(pt.x, pt.y);+  return true;+}+async function editorOpen(P) { return P.page.evaluate(() => { const e = document.getElementById('editor'); return !!e && e.classList.contains('show') && !!document.getElementById('imApply'); }); }+async function setField(P, sel, v) { await P.page.$eval(sel, (e, v) => { e.value = v; e.dispatchEvent(new Event('input', { bubbles: true })); e.dispatchEvent(new Event('change', { bubbles: true })); }, v); }+// One edit: open the row, change its status and its end, Apply. Returns what was written.+async function editRow(P, row, n) {+  if (!(await clickBar(P, row.id))) return null;+  for (let k = 0; k < 20 && !(await editorOpen(P)); k++) await sleep(50);+  if (!(await editorOpen(P))) return null;+  const status = ['not-started', 'in-progress', 'blocked', 'complete'][n % 4];+  const end = shiftDay(row.start, 6 + (n % 9));+  await setField(P, '#imStatus', status);+  await setField(P, '#imEnd', end);+  await P.page.click('#imApply');+  await sleep(120);+  return { uid: row.uid, status, end };+}+const shiftDay = (iso, n) => new Date(Date.UTC(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10)) + n * 86400000).toISOString().slice(0, 10);+async function lookAtRow(P, id) { if (await clickBar(P, id)) { await sleep(250 + Math.random() * 300); await P.page.keyboard.press('Escape'); await P.page.keyboard.press('Escape'); } }+async function scrollAround(P) { await P.page.mouse.move(900, 500); for (let k = 0; k < 4; k++) { await P.page.mouse.wheel({ deltaY: (Math.random() < 0.5 ? -1 : 1) * (200 + Math.random() * 500) }); await sleep(60); } }+async function toggleLocalView(P, n) {+  // Views that are the reader's own and never reach anyone else: they must cost nothing remote.+  const k = n % 3;+  if (k === 0 && await P.page.$('#currentChip')) { await P.page.click('#currentChip'); await sleep(300); await P.page.click('#currentChip'); }+  else if (k === 1 && await P.page.$('#btnCollapseAll')) { await P.page.click('#btnCollapseAll'); await sleep(300); await P.page.click('#btnCollapseAll'); }+  else if (await P.page.$('#ganttOwner')) { const o = await P.page.$$eval('#ganttOwner option', os => os.map(o => o.value).filter(Boolean)); if (o.length) { await P.page.select('#ganttOwner', o[n % o.length]); await sleep(300); await P.page.select('#ganttOwner', ''); } }+}++// The rows each person edits: their own, so an edit can only be lost by the app, never by+// two people writing the same row (that case has its own scenario).+async function planRows(S, sid) {+  const plan = await (await asUser(S, sid, '/export/json')).json();+  return (plan.items || []).filter(r => r.type !== 'group');+}++// Everyone has caught up: no pull, save or pending save anywhere, and every tab on the+// server's revision. Returns how long that took.+async function quiesce(S, people, timeout = 30000) {+  const t0 = Date.now(); let stable = 0, lastRev = -1;+  while (Date.now() - t0 < timeout) {+    const rev = await serverRev(S);+    const st = await Promise.all(people.map(P => P.page.evaluate(() => window.__collab ? { busy: window.__collab.busy ? window.__collab.busy() : false, rev: window.__collab.rev() } : { busy: false, rev: -1 }).catch(() => ({ busy: true, rev: -2 }))));+    const settled = st.every(s => !s.busy && (s.rev === rev || s.rev === -1));+    stable = settled && rev === lastRev ? stable + 1 : 0; lastRev = rev;+    if (stable >= 3) return Date.now() - t0;+    await sleep(300);+  }+  return -1;+}+// Every tab holds exactly what the server holds, field by field, row by row.+async function convergence(S, people) {+  const srv = new Map((await planRows(S, people[0].sid)).filter(r => r.uid).map(r => [r.uid, r]));+  const norm = r => JSON.stringify([r.name || '', r.owner || '', r.status || '', r.start || '', r.end || '', r.date || '', r.phase, (r.deps || []).slice().sort(), r.description || r.desc || '']);+  const diffs = [];+  for (const P of people) {+    const rows = await P.page.evaluate(() => window.__collab.rows());+    const mine = new Map(rows.map(r => [r.uid, r]));+    if (mine.size !== srv.size) diffs.push(`${P.name}: ${mine.size} rows, server ${srv.size}`);+    for (const [u, s] of srv) { const m = mine.get(u); if (!m) { diffs.push(`${P.name}: missing ${u}`); continue; } if (norm(m) !== norm(s)) diffs.push(`${P.name}: ${u} ${norm(m)} vs server ${norm(s)}`); }+  }+  return diffs;+}++// ════════════════════════════════════════════════════════════════════════════+// Scenarios+// ════════════════════════════════════════════════════════════════════════════++// The meeting starts: people join one after another. Each tab must get ready within budget+// with the others already open, without forcing a layout storm, and a join must cost the+// tabs already open next to nothing (they only learn who is here).+async function joinScenario(carbon) {+  scenario = 'join';+  const S = await startServer(carbon);+  const people = [];+  const joinCost = [];+  for (let i = 0; i < USERS; i++) {+    const P = await openPerson(S, carbon, i);+    const before = await Promise.all(people.map(metrics));+    const m0 = await metrics(P);+    const t0 = Date.now();+    await goReady(P, S);+    await sleep(800);+    const m1 = await metrics(P);+    P.startup = { readyMs: P.readyMs, taskMs: Math.round((m1.TaskDuration - m0.TaskDuration) * 1000), layouts: m1.LayoutCount - m0.LayoutCount };+    const after = await Promise.all(people.map(metrics));+    const wall = (Date.now() - t0) / 1000;+    people.forEach((Q, k) => joinCost.push(pct(after[k].TaskDuration - before[k].TaskDuration, wall)));+    people.push(P);+  }+  const ready = people.map(P => P.startup.readyMs), layouts = people.map(P => P.startup.layouts);+  rec('startup', people.map(P => ({ name: P.name, ...P.startup })));+  rec('joinCostPctOfOthers', joinCost);+  check(`each of ${USERS} people is ready within ${BUDGETS.READY_MS} ms of opening the plan`, Math.max(...ready) <= BUDGETS.READY_MS, `slowest ${fmtMs(Math.max(...ready))}, median ${fmtMs(ready.slice().sort((a, b) => a - b)[ready.length >> 1])}`);+  check(`no tab forces more than ${BUDGETS.STARTUP_LAYOUTS} layouts while it starts (the freeze forced ~70,000)`, Math.max(...layouts) <= BUDGETS.STARTUP_LAYOUTS, `most ${Math.max(...layouts)}`);+  check('someone joining costs the tabs already open less than 15% of a core', !joinCost.length || Math.max(...joinCost) < 15, `worst ${joinCost.length ? Math.max(...joinCost) : 0}%`);+  const chips = await Promise.all(people.map(P => P.page.evaluate(() => (document.querySelector('#presence .presence-n') || {}).textContent || '')));+  check(`every tab shows all ${USERS} people in the legend`, chips.every(c => c === `${USERS} tabs`), chips.join(' | '));+  const errs = people.flatMap(P => P.errors.map(e => `${P.name}: ${e}`));+  check('no page errors while joining', !errs.length, errs.slice(0, 3).join(' | '));+  for (const P of people) await closePerson(P);+  await stopServer(S);+}++// One save, everyone else: each person edits once in turn while the others sit in different+// lenses (chart, table, setup, analytics), so every lens's merge path is measured.+async function mergeScenario(carbon) {+  scenario = 'merge';+  const S = await startServer(carbon);+  // The owner takes Analytics (the team does not have it); the others share the rest.+  const lenses = ['analytics', 'gantt', 'table', 'setup'];+  const people = [];+  for (let i = 0; i < USERS; i++) {+    const P = await openPerson(S, carbon, i, { view: lenses[i % lenses.length] }); await goReady(P, S);+    P.view = await P.page.evaluate(() => document.documentElement.dataset.view);+    people.push(P);+  }+  await sleep(1500);+  if (!(await hasHooks(people[0]))) skip('row-level checks', 'this build has no __collab.rows');+  const rows = (await planRows(S, people[0].sid)).filter(r => r.type === 'task' && r.id && r.start);+  const perLens = {}, visible = [], layouts = [], costs = [];+  for (let k = 0; k < USERS; k++) {+    const editor = people[k];+    if (editor.view !== 'gantt') { await editor.page.click('.vt-btn[data-view="gantt"]'); await sleep(600); }+    const others = people.filter(P => P !== editor);+    const m0 = await Promise.all(others.map(metrics)), mc0 = await Promise.all(others.map(merges));+    const saves0 = others.map(P => P.req.save);+    const row = rows[k * 7 % rows.length];+    const t0 = Date.now();+    const wrote = await editRow(editor, row, k);+    if (!wrote) { check(`${editor.name} could open a row to edit`, false, row.name); continue; }+    // Every other tab shows it.+    const seen = await Promise.all(others.map(async (P, j) => {+      const t = Date.now();+      while (Date.now() - t < 15000) { if ((await merges(P)) > mc0[j]) return Date.now() - t0; await sleep(40); }+      return -1;+    }));+    await sleep(500);+    const m1 = await Promise.all(others.map(metrics));+    others.forEach((P, j) => {+      const d = busySince(m0[j], m1[j]); const ms = Math.round(d.task * 1000);+      costs.push(ms); layouts.push(d.layouts); visible.push(seen[j]);+      (perLens[P.view] = perLens[P.view] || []).push(ms);+    });+    check(`${editor.name}'s edit reaches every other tab`, seen.every(v => v >= 0), seen.join(', '));+    check(`and no other tab saved anything in answer (no save echo)`, others.every((P, j) => P.req.save === saves0[j]), others.map((P, j) => P.req.save - saves0[j]).join(','));+    if (editor.view !== 'gantt') { await editor.page.evaluate(v => { const b = document.querySelector(`.vt-btn[data-view="${v}"]`); if (b && b.offsetParent) b.click(); }, editor.view); await sleep(400); }+  }+  rec('mergeMsPerTab', costs); rec('mergeLayouts', layouts); rec('visibleMs', visible); rec('perLens', perLens);+  check(`a save costs each other tab under ${BUDGETS.MERGE_MS} ms of main thread (the freeze was 15,000+)`, Math.max(...costs) <= BUDGETS.MERGE_MS, `worst ${fmtMs(Math.max(...costs))}, p95 ${fmtMs(p95(costs))}; by lens ${Object.entries(perLens).map(([k, v]) => k + ' ' + Math.max(...v)).join(', ')}`);+  check(`and forces at most ${BUDGETS.MERGE_LAYOUTS} layouts (the freeze forced ~69,000)`, Math.max(...layouts) <= BUDGETS.MERGE_LAYOUTS, `most ${Math.max(...layouts)}`);+  check(`and shows up in every tab within ${BUDGETS.VISIBLE_MS} ms`, visible.every(v => v >= 0 && v <= BUDGETS.VISIBLE_MS), `slowest ${fmtMs(Math.max(...visible))}`);+  const pulls = await Promise.all(people.map(P => P.page.evaluate(() => window.__collab.pulls ? window.__collab.pulls() : -1)));+  if (pulls[0] >= 0) check('each tab pulled the plan no more often than it was told of a save', people.every((P, j) => pulls[j] <= USERS), pulls.join(','));+  if (await hasHooks(people[0])) { await quiesce(S, people); const diffs = await convergence(S, people); check('every tab holds exactly what the server holds', !diffs.length, diffs.slice(0, 4).join(' | ')); }+  const errs = people.flatMap(P => P.errors.map(e => `${P.name}: ${e}`));+  check('no page errors', !errs.length, errs.slice(0, 3).join(' | '));+  for (const P of people) await closePerson(P);+  await stopServer(S);+}++// The meeting: everyone at once, for --seconds. Each person looks at rows, edits their own,+// scrolls, and flips their own filters and folds. Then every tab must agree with the server,+// and every edit anyone made must be in it.+async function meetingScenario(carbon) {+  scenario = 'meeting';+  const S = await startServer(carbon);+  const people = [];+  for (let i = 0; i < USERS; i++) { const P = await openPerson(S, carbon, i, { view: 'gantt' }); await goReady(P, S); people.push(P); }+  await sleep(1500);+  const hooks = await hasHooks(people[0]);+  const rows = (await planRows(S, people[0].sid)).filter(r => r.type === 'task' && r.id && r.start && !/cancel/i.test(r.status || ''));+  const mine = people.map((P, k) => rows.filter((r, j) => j % USERS === k).slice(0, 12));+  const expected = new Map(); let edits = 0, actions = 0;+  // The server is watched while it works: /health latency, and its memory.+  const lat = []; let rssMax = 0, sampling = true;+  const sampler = (async () => { while (sampling) { const t = Date.now(); try { await fetch(S.base + '/health'); lat.push(Date.now() - t); } catch {} rssMax = Math.max(rssMax, serverRss(S)); await sleep(400); } })();+  await Promise.all(people.map(P => longTasks(P)));+  const m0 = await Promise.all(people.map(metrics)), t0 = Date.now();+  await Promise.all(people.map(async (P, k) => {+    let n = 0;+    while (Date.now() - t0 < SECONDS * 1000) {+      const roll = Math.random(); n++; actions++;+      try {+        if (roll < 0.35) await lookAtRow(P, mine[k][n % mine[k].length].id);+        else if (roll < 0.6) { const w = await editRow(P, mine[k][n % mine[k].length], n + k); if (w) { expected.set(w.uid, w); edits++; } }+        else if (roll < 0.8) await scrollAround(P);+        else await toggleLocalView(P, n);+      } catch (e) { P.errors.push('action: ' + e.message.slice(0, 120)); }+      await sleep(1500 + Math.random() * 2500);+    }+  }));+  const wall = (Date.now() - t0) / 1000;+  const m1 = await Promise.all(people.map(metrics));+  const lts = await Promise.all(people.map(P => longTasks(P)));+  sampling = false; await sampler;+  const busy = people.map((P, j) => pct(m1[j].TaskDuration - m0[j].TaskDuration, wall));+  const worstLt = lts.map(a => a.length ? Math.max(...a) : 0);+  rec('busyPct', busy); rec('longestTaskMs', worstLt); rec('edits', edits); rec('actions', actions);+  rec('serverHealthP95', p95(lat)); rec('serverRssMB', +rssMax.toFixed(0));+  console.log(`       ${USERS} people, ${wall.toFixed(0)} s, ${actions} actions, ${edits} edits; busy per tab ${busy.join('% ')}%; longest freeze per tab ${worstLt.join(', ')} ms`);+  check(`every tab stays under ${BUDGETS.MEETING_BUSY}% of a core through the meeting`, Math.max(...busy) <= BUDGETS.MEETING_BUSY, `worst ${Math.max(...busy)}%, mean ${(busy.reduce((a, b) => a + b, 0) / busy.length).toFixed(1)}%`);+  check(`no freeze longer than ${BUDGETS.LONG_TASK_MS} ms in any tab`, Math.max(...worstLt) <= BUDGETS.LONG_TASK_MS, `longest ${fmtMs(Math.max(...worstLt))}`);+  check(`the server answers within ${BUDGETS.SERVER_P95_MS} ms (p95) while it all happens`, p95(lat) <= BUDGETS.SERVER_P95_MS, `p95 ${fmtMs(p95(lat))}, ${lat.length} samples`);+  check(`and stays under ${BUDGETS.SERVER_RSS_MB} MB`, rssMax <= BUDGETS.SERVER_RSS_MB, `${rssMax.toFixed(0)} MB`);+  check('every save was an edit someone made (no save storms or echoes)', people.reduce((a, P) => a + P.req.save, 0) <= edits * 2 + USERS, `${people.reduce((a, P) => a + P.req.save, 0)} saves for ${edits} edits`);+  if (hooks) {+    const q = await quiesce(S, people);+    check('every tab settles once the meeting stops', q >= 0, q >= 0 ? `${q} ms` : 'still busy after 30 s');+    const diffs = await convergence(S, people);+    check('every tab holds exactly what the server holds, row by row', !diffs.length, diffs.slice(0, 4).join(' | '));+    const srv = new Map((await planRows(S, people[0].sid)).map(r => [r.uid, r]));+    const lost = [...expected.values()].filter(w => { const r = srv.get(w.uid); return !r || r.status !== w.status || r.end !== w.end; });+    check(`every one of the ${expected.size} rows edited holds its last edit (no edit lost)`, !lost.length, lost.slice(0, 3).map(w => `${w.uid} wanted ${w.status}/${w.end} got ${JSON.stringify(srv.get(w.uid) && [srv.get(w.uid).status, srv.get(w.uid).end])}`).join(' | '));+  } else skip('convergence and lost edits', 'this build has no __collab.rows');+  const errs = people.flatMap(P => P.errors.map(e => `${P.name}: ${e}`));+  check('no page errors in any tab', !errs.length, errs.slice(0, 3).join(' | '));+  for (const P of people) await closePerson(P);+  await stopServer(S);+}++// Everyone clicking around: each selection is announced to the others, who mark the row with+// the person's initials. One person only watches: what the others' clicking costs them is the+// price of presence, and it must be small. The clickers' own clicks are measured too.+async function presenceScenario(carbon) {+  scenario = 'presence';+  const S = await startServer(carbon);+  const people = [];+  for (let i = 0; i < USERS; i++) { const P = await openPerson(S, carbon, i); await goReady(P, S); people.push(P); }+  await sleep(1500);+  const rows = (await planRows(S, people[0].sid)).filter(r => r.type === 'task' && r.id && r.start && !/cancel/i.test(r.status || ''));+  const watcher = people[0], clickers = people.slice(1);+  const clicks = [];+  const w0 = await metrics(watcher), t0 = Date.now();+  await Promise.all(clickers.map(async (P, k) => {+    let n = 0;+    while (Date.now() - t0 < 20000) {+      const id = rows[(k * 31 + n++ * 7) % rows.length].id;+      const m0 = await metrics(P), c0 = Date.now();+      if (await clickBar(P, id)) {+        for (let i = 0; i < 40 && !(await editorOpen(P)); i++) await sleep(25);+        const m1 = await metrics(P);+        clicks.push({ wall: Date.now() - c0, task: Math.round((m1.TaskDuration - m0.TaskDuration) * 1000) });+        await sleep(300); await P.page.keyboard.press('Escape'); await P.page.keyboard.press('Escape');+      }+      await sleep(300);+    }+  }));+  const wall = (Date.now() - t0) / 1000, w1 = await metrics(watcher);+  const busy = pct(w1.TaskDuration - w0.TaskDuration, wall);+  const cw = clicks.map(c => c.wall), ct = clicks.map(c => c.task);+  rec('watcherBusyPct', busy); rec('clicks', clicks.length); rec('clickWallP95', p95(cw)); rec('clickTaskP95', p95(ct)); rec('focusPosts', people.map(P => P.req.focus));+  check(`${clickers.length} people clicking around cost a watching tab under ${BUDGETS.PRESENCE_BUSY}% of a core`, busy <= BUDGETS.PRESENCE_BUSY, `${busy}% over ${wall.toFixed(0)} s, ${clicks.length} clicks`);+  check(`a click that opens a row costs its own tab under ${BUDGETS.CLICK_MS} ms (p95)`, p95(ct) <= BUDGETS.CLICK_MS, `p95 ${fmtMs(p95(ct))}, median ${fmtMs(ct.slice().sort((a, b) => a - b)[ct.length >> 1] || 0)}`);+  check(`and puts the editor on screen within ${BUDGETS.CLICK_WALL_MS} ms (p95)`, p95(cw) <= BUDGETS.CLICK_WALL_MS, `p95 ${fmtMs(p95(cw))}`);+  check('each click announced the row once at most (no presence storm)', people.every(P => P.req.focus <= clicks.length * 2 + 2), people.map(P => P.req.focus).join(','));+  // Person 1 holds one row; every other tab marks it with their initials.+  const row = rows[3];+  await clickBar(people[1], row.id); await sleep(1200);+  const marks = await Promise.all(people.map(P => P.page.evaluate(() => [...document.querySelectorAll('.label-row.peer-focus')].map(t => (t.querySelector('.peer-chip') || {}).textContent || '').join(','))));+  check(`a row ${people[1].name} has open shows their initials in every other tab`, marks.every((m, j) => j === 1 ? !m.includes('KM') : m.includes('KM')), marks.join(' | '));+  await people[1].page.keyboard.press('Escape'); await people[1].page.keyboard.press('Escape'); await sleep(1200);+  const cleared = await Promise.all(people.map(P => P.page.evaluate(() => document.querySelectorAll('.label-row.peer-focus').length)));+  check('and letting go clears it everywhere', cleared.every(n => n === 0), cleared.join(','));+  const errs = people.flatMap(P => P.errors.map(e => `${P.name}: ${e}`));+  check('no page errors', !errs.length, errs.slice(0, 3).join(' | '));+  for (const P of people) await closePerson(P);+  await stopServer(S);+}++// Nothing happening: an open tab must cost nothing, including the owner's with the critical+// path on (its endless glow kept a core at 100% through 0.75.0).+async function idleScenario(carbon) {+  scenario = 'idle';+  const S = await startServer(carbon);+  const people = [];+  for (let i = 0; i < Math.min(USERS, 4); i++) { const P = await openPerson(S, carbon, i); await goReady(P, S); people.push(P); }+  await sleep(2000);+  let m0 = await Promise.all(people.map(metrics)); await sleep(10000); let m1 = await Promise.all(people.map(metrics));+  const busy = people.map((P, j) => pct(m1[j].TaskDuration - m0[j].TaskDuration, 10));+  rec('busyPct', busy);+  check(`an idle tab costs under ${BUDGETS.IDLE_BUSY}% of a core`, Math.max(...busy) <= BUDGETS.IDLE_BUSY, busy.join('% ') + '%');+  const owner = people[0];+  if (await owner.page.$('#btnCrit')) {+    await owner.page.click('#btnCrit'); await sleep(1500);+    m0 = await metrics(owner); await sleep(10000); m1 = await metrics(owner);+    const b = pct(m1.TaskDuration - m0.TaskDuration, 10); rec('critBusyPct', b);+    check(`with the critical path on, idle stays under ${BUDGETS.CRIT_IDLE_BUSY}% (it was 100%)`, b <= BUDGETS.CRIT_IDLE_BUSY, b + '%');+    await owner.page.click('#btnCrit');+  } else skip('critical path idle', 'no Critical button for the owner here');+  // The team cannot reach the Critical button, so a mode they saved before it was hidden+  // must not be on for them.+  const member = people[1];+  await member.page.evaluate(() => { try { const k = 'adom-gantt:prefs'; const p = JSON.parse(localStorage.getItem(k) || '{}'); p.critMode = 'phase'; localStorage.setItem(k, JSON.stringify(p)); } catch (e) {} });+  await member.page.reload({ waitUntil: 'domcontentloaded' }); await member.page.waitForSelector('html[data-ready="1"]', { timeout: 120000 }); await sleep(1500);+  const crit = await member.page.evaluate(() => document.querySelectorAll('.bar.crit, .dep-svg .crit').length);+  check('a teammate with an old critical-path setting does not get it (they cannot turn it off)', crit === 0, `${crit} glowing`);+  for (const P of people) await closePerson(P);+  await stopServer(S);+}++// A long session: one tab takes 150 saves from others. After a forced garbage collection its+// memory, its DOM and its listeners must be where they started, give or take.+async function soakScenario(carbon) {+  scenario = 'soak';+  const S = await startServer(carbon);+  const P = await openPerson(S, carbon, 0); await goReady(P, S); await sleep(1500);+  const writer = await signIn(S, carbon, 'u1');+  await P.cdp.send('HeapProfiler.enable');+  const gc = async () => { await P.cdp.send('HeapProfiler.collectGarbage'); await sleep(400); return metrics(P); };+  const g0 = await gc();+  const N = 150; let worst = 0, n = 0;+  for (let i = 0; i < N; i++) {+    const st = await (await asUser(S, writer, '/state')).json(); const rev = st.stateRev; delete st.stateRev; delete st.integrity;+    const it = st.items.find((x, j) => j === i % Math.max(1, st.items.length)) || st.items[0];+    if (!it) { st.items.push({ uid: st.order[i % st.order.length], owner: 'Soak ' + i }); } else it.owner = 'Soak ' + i;+    const b = await merges(P), t0 = Date.now();+    await asUser(S, writer, '/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign(st, { baseRev: rev, client: 'soak' })) });+    while ((await merges(P)) === b && Date.now() - t0 < 10000) await sleep(20);+    worst = Math.max(worst, Date.now() - t0); n++;+  }+  await sleep(800);+  const g1 = await gc();+  const heap = (g1.JSHeapUsedSize - g0.JSHeapUsedSize) / 1048576, nodes = g1.Nodes - g0.Nodes, lis = g1.JSEventListeners - g0.JSEventListeners;+  rec('heapGrowthMB', +heap.toFixed(2)); rec('nodeGrowth', nodes); rec('listenerGrowth', lis); rec('slowestMergeMs', worst);+  check(`${n} merges later the heap grew less than ${BUDGETS.SOAK_HEAP_MB} MB`, heap <= BUDGETS.SOAK_HEAP_MB, `${heap.toFixed(2)} MB`);+  check(`the DOM grew by fewer than ${BUDGETS.SOAK_NODES} nodes`, nodes <= BUDGETS.SOAK_NODES, `${nodes} nodes`);+  check(`and the event listeners by fewer than ${BUDGETS.SOAK_LISTENERS}`, lis <= BUDGETS.SOAK_LISTENERS, `${lis}`);+  check(`no merge took longer than ${BUDGETS.VISIBLE_MS} ms to show`, worst <= BUDGETS.VISIBLE_MS, fmtMs(worst));+  check('no page errors', !P.errors.length, P.errors.slice(0, 3).join(' | '));+  await closePerson(P);+  await stopServer(S);+}++// Two people on the SAME moment: on different rows both edits survive (the second save is+// refused, the tab pulls and saves again); on the SAME row the later save wins that row,+// which is the documented limit until field-level merging, and both tabs still agree.+async function conflictScenario(carbon) {+  scenario = 'conflict';+  const S = await startServer(carbon);+  const A = await openPerson(S, carbon, 0), Bp = await openPerson(S, carbon, 1);+  await goReady(A, S); await goReady(Bp, S); await sleep(1500);+  const hooks = await hasHooks(A);+  const rows = (await planRows(S, A.sid)).filter(r => r.type === 'task' && r.id && r.start && !/cancel/i.test(r.status || ''));+  const [r1, r2] = [rows[5], rows[60]];+  const [w1, w2] = await Promise.all([editRow(A, r1, 1), editRow(Bp, r2, 2)]);+  await quiesce(S, [A, Bp]);+  let srv = new Map((await planRows(S, A.sid)).map(r => [r.uid, r]));+  check('two people saving at the same moment on different rows: both edits survive', w1 && w2 && srv.get(r1.uid).status === w1.status && srv.get(r2.uid).status === w2.status,+    JSON.stringify([w1 && w1.status, srv.get(r1.uid).status, w2 && w2.status, srv.get(r2.uid).status]));+  const banners = await Promise.all([A, Bp].map(P => P.page.evaluate(() => document.getElementById('conflictBar').classList.contains('show'))));+  check('and neither of them sees the conflict banner', banners.every(b => !b), banners.join(','));+  const r3 = rows[9];+  const [x1, x2] = await Promise.all([editRow(A, r3, 5), editRow(Bp, r3, 6)]);+  await quiesce(S, [A, Bp]);+  srv = new Map((await planRows(S, A.sid)).map(r => [r.uid, r]));+  const got = srv.get(r3.uid);+  check('the same row from two people at once: the row ends as one of them wrote it, whole', x1 && x2 && [x1, x2].some(w => got.status === w.status && got.end === w.end), JSON.stringify([x1, x2, got && [got.status, got.end]]));+  if (hooks) { const diffs = await convergence(S, [A, Bp]); check('and both tabs agree with the server', !diffs.length, diffs.slice(0, 3).join(' | ')); }+  // A is typing a new end date in the editor when B's save lands. The merge must not put the+  // stored date back over what A typed (it did through 0.75.0, and A's Apply then saved the+  // old date with the new status: an edit lost with every tab agreeing about it).+  if (hooks) {+    const r4 = rows[30], r5 = rows[90];+    if (await clickBar(A, r4.id)) {+      for (let k = 0; k < 20 && !(await editorOpen(A)); k++) await sleep(50);+      const typed = shiftDay(r4.start, 11);+      await setField(A, '#imEnd', typed);+      const b0 = await merges(A);+      await editRow(Bp, r5, 3);+      for (const t = Date.now(); (await merges(A)) === b0 && Date.now() - t < 10000;) await sleep(50);+      const still = await A.page.$eval('#imEnd', e => e.value);+      await A.page.click('#imApply'); await sleep(200);+      await quiesce(S, [A, Bp]);+      const got = new Map((await planRows(S, A.sid)).map(r => [r.uid, r])).get(r4.uid);+      check('a date typed in the editor survives someone else\'s save landing, and Apply saves it', still === typed && got && got.end === typed, JSON.stringify({ typed, inField: still, saved: got && got.end }));+    } else check('the editor opened for the typing check', false, r4.name);+  }+  for (const P of [A, Bp]) await closePerson(P);+  await stopServer(S);+}++// A tab that drops off the live channel (a laptop lid, a flaky connection) misses saves, then+// comes back and catches up on its own.+async function reconnectScenario(carbon) {+  scenario = 'reconnect';+  const S = await startServer(carbon);+  const A = await openPerson(S, carbon, 0), Bp = await openPerson(S, carbon, 1);+  await goReady(A, S); await goReady(Bp, S); await sleep(1500);+  if (!(await hasHooks(A))) { skip('reconnect', 'this build has no __collab.rows'); for (const P of [A, Bp]) await closePerson(P); await stopServer(S); return; }+  await Bp.page.evaluate(() => window.__collab.pause());+  const rows = (await planRows(S, A.sid)).filter(r => r.type === 'task' && r.id && r.start && !/cancel/i.test(r.status || ''));+  for (let i = 0; i < 4; i++) { await editRow(A, rows[20 + i], i); await sleep(1000); }+  const behind = await Bp.page.evaluate(() => window.__collab.rev());+  const rev = await serverRev(S);+  check('a tab off the live channel does not see the saves it missed', behind < rev, `${behind} vs ${rev}`);+  const t0 = Date.now();+  await Bp.page.evaluate(() => window.__collab.resume());+  while ((await Bp.page.evaluate(() => window.__collab.rev())) < rev && Date.now() - t0 < 10000) await sleep(50);+  const took = Date.now() - t0;+  check(`back on the channel it catches up within ${BUDGETS.VISIBLE_MS} ms`, took <= BUDGETS.VISIBLE_MS, fmtMs(took));+  await quiesce(S, [A, Bp]);+  const diffs = await convergence(S, [A, Bp]);+  check('and holds what the server holds', !diffs.length, diffs.slice(0, 3).join(' | '));+  for (const P of [A, Bp]) await closePerson(P);+  await stopServer(S);+}++// ════════════════════════════════════════════════════════════════════════════+const t0 = Date.now();+let exitCode = 1;+const carbon = await fakeCarbon();+try {+  BROWSER = await puppeteer.launch({ executablePath: CHROME, headless: 'new', protocolTimeout: 600000,+    args: ['--no-sandbox', '--disable-dev-shm-usage', '--hide-scrollbars'] });+  console.log(`adom-gantt collaboration suite: ${USERS} people, ${PLAN ? basename(PLAN) : 'generated plan'}, CPU ${THROTTLE}x, app ${APP}`);+  if (want('join')) await joinScenario(carbon);+  if (want('merge')) await mergeScenario(carbon);+  if (want('meeting')) await meetingScenario(carbon);+  if (want('presence')) await presenceScenario(carbon);+  if (want('idle')) await idleScenario(carbon);+  if (want('soak')) await soakScenario(carbon);+  if (want('conflict')) await conflictScenario(carbon);+  if (want('reconnect')) await reconnectScenario(carbon);+  exitCode = fail ? 1 : 0;+} catch (e) {+  console.error('\nSUITE CRASHED:', e && e.stack || e);+  fail++; failures.push(`[${scenario}] suite crashed: ${e && e.message}`);+} finally {+  for (const S of servers.slice()) await stopServer(S);+  try { await BROWSER?.close(); } catch {}+  carbon.close();+  report.pass = pass; report.fail = fail; report.skipped = skipped; report.failures = failures; report.seconds = Math.round((Date.now() - t0) / 1000);+  if (REPORT) writeFileSync(REPORT, JSON.stringify(report, null, 1));+  if (!KEEP) { try { rmSync(TMP, { recursive: true, force: true }); } catch {} } else console.log('kept', TMP);+  console.log(`\n${pass} passed, ${fail} failed${skipped ? `, ${skipped} skipped` : ''} in ${report.seconds} s`);+  if (failures.length) { console.log('Failures:'); failures.forEach(f => console.log('  - ' + f)); }+  process.exit(exitCode);+}
test/server_test.mjs+2
@@ -193,6 +193,8 @@ async function main() {   need.forEach(n => ok(md.includes(n), 'md line: ' + n, md.split('\n').filter(l => /tag/.test(l))));   const health = await getJ(A, '/health');   ok(!health.integrity || health.integrity.ok !== false, 'hash chain still whole after the writes', health.integrity);+  const stNow = await getJ(A, '/state');+  ok(typeof health.stateRev === 'number' && health.stateRev === stNow.stateRev, '/health reports the plan\'s stateRev (0.75.1), the revision every tab saves against', [health.stateRev, stNow.stateRev]);    // CSV round trip WITH the column: export -> import -> export   const exBefore = await getJ(A, '/export/json');
test/ui_test.mjs+6−1
@@ -734,7 +734,12 @@ async function main() {         await page.goto(`${sbUrl}#fs=bogus`); await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);         check('a bad fragment value falls back to 100%', await fsPct(page) === '100%', await fsPct(page));         await page.close();-      } finally { await new Promise(r => sandboxSrv.close(r)); }+      } finally {+        // Chrome keeps an idle socket to this server open, and close() waits for it: up to+        // the whole run's watchdog, once (0.75.1). The sockets are dropped first.+        sandboxSrv.closeAllConnections?.();+        await new Promise(r => sandboxSrv.close(r));+      }     }      if (wantAny('collapse all')) {