← Commit history

Publish 0.74.1

Drew Owens ·e443917f41 · ·parent 9894135
6 files changed +229−24
README.md+1−1
@@ -74,7 +74,7 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc  **Current and Overdue.** Two chips in the legend, for reviewing a plan with the team. **Current** (0.70.0) shows only what has started (start date today or earlier) and is not complete or cancelled: the work under way, plus anything that should have started and has not. Future and finished rows are hidden, in the chart and the table. **Overdue** shows only what is past its deadline and not done. Turn both on for just the overdue rows. Each counts tasks and milestones separately, and neither changes the plan. -**Legend filtering.** Click any phase, "Milestone", "Dependencies" or "Today" in the legend bar to toggle visibility. Dimmed items fade to 15% opacity; hiding "Today" removes the marker line. The legend wraps onto as many lines as the plan needs, so a plan with twenty sections shows all twenty chips. When the sections carry tags (0.59.0), the legend leads with a chip per tag and the twenty section chips fold behind one "20 sections" chip; pick a tag and only the sections carrying it are shown, in the chart and the table, with their own chips back in the legend. Pick several to see any of them; Clear tags shows everything. It is your view only, and it survives a refresh. Since 0.64.0 the legend also leads with an **Owner** pick (every owner with their row count, plus **Unassigned**, shared with the table's owner filter) and an **Overdue** chip: rows whose end date or milestone date has passed without being marked complete or cancelled get a red edge and ring, and the chip shows only them. Overdue is worked out from the dates every time the page draws and at midnight; it is never written into the plan.+**Legend filtering.** Click any phase, "Milestone", "Dependencies" or "Today" in the legend bar to toggle visibility. Dimmed items fade to 15% opacity; hiding "Today" removes the marker line. The legend wraps onto as many lines as the plan needs, so a plan with twenty sections shows all twenty chips. When the sections carry tags (0.59.0), the legend leads with a chip per tag and the twenty section chips fold behind one "20 sections" chip; pick a tag and only the sections carrying it are shown, in the chart and the table, with their own chips back in the legend. Pick several to see any of them; Clear tags shows everything. It is your view only, and it survives a refresh. Since 0.64.0 the legend also leads with an **Owner** pick (every owner with their row count, plus **Unassigned**, shared with the table's owner filter) and an **Overdue** chip: rows whose end date or milestone date has passed without being marked complete or cancelled get a red edge and ring, and the chip shows only them. Overdue is worked out from the dates every time the page draws and at midnight; it is never written into the plan. Changing any of these filters, or Collapse all and Expand all, keeps your place: the row at the top of the chart stays where it is, or, if the filter hides it, the nearest row above it that still shows takes its spot. A row someone else adds or deletes above you does not move your view either (0.74.1).  **How long, and how long left.** The header says **Project Duration** and **Time Remaining**, worked out from the plan's own first and last dates and today, never from the cell size, leaving cancelled work out, and updated on every edit. They stay whole at every window width: the header sheds button words, then wraps, before it lets them be cut. A printed chart names the plan in a title line of its own. Hover either for the exact dates and the calendar and working-day counts. The plan itself is named once, in the brand block at the far left; its title and description are on that block's tooltip. 
docs/BUILD-NOTES.md+31
@@ -3,6 +3,37 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-28 (0.74.1, names and bars stay together after a filter)++Drew: "super out of sync when I clicked to filter unassigned" (the names and the bars showed+different rows), then "scrolling resynced".++Cause, two halves. The arrow layer sized itself to max(arrows, bars.scrollHeight), but it sits+inside the bars, so the measure counted its own last height: it grew and never shrank, and+after a filter the chart stayed as tall as the whole plan (17,997 px for 59 rows on screen).+And the browser's scroll anchoring adjusted the names column by itself while the chart kept+its offset. On a copy of the live plan, picking Unassigned at 1,500 px left the names at 350+and the chart at 1,500, every row 1,150 px out, until a scroll ran syncLabelScroll.++Fix: drawDeps resets the layer to 1 px before measuring. Scroll anchoring is off in both+panes (overflow-anchor: none). A filter (owner, Current, Overdue, tags) and Collapse/Expand+all hold the reader's place by hand: the first row in view keeps its spot on screen; if the+change hides it, the nearest row above it that still shows takes the spot (after Collapse+all that is its own section's header). A live merge of another person's save does the same,+so a row they add or delete above the reader no longer moves the reader's view (with the+anchoring off it would have moved both panes a row; before, it moved only the names). Both+panes move together through the timeline.+applyFilter and a single lane's fold also settle both panes (syncLabelScroll), and+updatePinnedHeader skips a header a filter hides (it read offsetTop 0 and won the pin).+Natural positions are read with every header's sticky lifted for one synchronous read+(class .unpinned, position static: relative would keep the sticky top and shift by 46 px).++Suite: new flow "scroll sync" (30 sections, a second tab deleting a row far above; fails 7+of 11 on 0.74.0, and the live check fails without the merge's keepPlace). Dedicated flows now+stop their own servers when they return (the full run had grown to 24 servers for a 20-port+range and crashed). Two readout checks still expected the old "16w 4d" format; they use the+oracle now.+ ## Build notes, 2026-10-28 (0.74.0, workday clock, team hides scheduling, who is on which row, bare links)  Drew: durations "as decimals e.g. 14.5 weeks remaining, then a "/" and working day count";
gantt.html+70−2
@@ -475,6 +475,10 @@ body.col-resizing { cursor: col-resize; user-select: none; }    scrollTop-driven half of the same invariant. */ .timeline-col { flex: 1; overflow: auto; scrollbar-width: none; overscroll-behavior: contain; } .timeline-col::-webkit-scrollbar { height: 0; width: 0; }+/* Scroll anchoring off in both panes (0.74.1). The browser anchors each pane by itself,+   so a filter that hid rows above the view moved the names and not the bars. keepPlace()+   holds the reader's row instead, in both panes at once. */+.labels-col, .timeline-col { overflow-anchor: none; } .tailroom { flex-shrink: 0; pointer-events: none; } @media print { .tailroom { display: none; } } .q-row, .m-row { display: flex; }@@ -1666,6 +1670,9 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va } :root[data-view="gantt"] .label-row.group.is-pinned, :root[data-view="gantt"] .bar-row.group.is-pinned { box-shadow: 0 3px 6px -3px rgba(0,0,0,0.35); }+/* Lifted only inside one synchronous read (unpinnedRead), never painted. Static, not+   relative: relative would keep the sticky's top and shift the header by it. */+.unpinned > .bar-row.group { position: static !important; } </style> </head> <body>@@ -2402,6 +2409,7 @@ function syncCollapseAllBtn(){     :`Collapse all ${plural(lanes.length,'section')} to their headers. This is your view only, it does not change the plan.`; } function setAllCollapsed(on){+  const mark=placeMark();   // With a tag filter on, "all" is the sections on screen: the hidden ones keep whatever   // they were, so clearing the filter does not reveal lanes folded by a press that never   // showed them.@@ -2410,6 +2418,7 @@ function setAllCollapsed(on){   prefs.collapsed=[...collapsed]; savePrefs();   applyCollapse();   layoutMsCaps();sizeTailroom();drawDeps();+  keepPlace(mark);   renderTable();   depHoverResync(); }@@ -2427,6 +2436,7 @@ function setCollapsed(ph,on,persist){   if(persist!==false){ prefs.collapsed=[...collapsed]; savePrefs(); }   applyCollapse();   layoutMsCaps();sizeTailroom();drawDeps();+  if(prefs.view==='gantt'){ syncLabelScroll(); updatePinnedHeader(); } } function wireLaneToggle(){ labelsEl.addEventListener('click',e=>{@@ -4630,6 +4640,9 @@ async function pullAndMerge(){   const srvDel=new Set(st.deleted||[]);   for(const [k,info] of [...remoteRemoved]){ if(!srvDel.has(k)&&!dirtyDel.has(k)){ remoteRemoved.delete(k); restoreRow(info.r,Math.min(info.idx,items.length),[]); deletedKeys.delete(k); } }   st.deleted=[...new Set([...srvDel,...dirtyDel])].filter(k=>!dirtyUids.has(k)||dirtyDel.has(k));+  // Another person's rows can come, go or move above the one this reader is looking at;+  // it stays where it is on screen, and the two panes stay one scroll (0.74.1).+  const mark=placeMark();   // 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.@@ -4644,6 +4657,7 @@ async function pullAndMerge(){   if(!tblEditing)renderTable();   if(prefs.view==='analytics')renderAnalytics(); else if(prefs.view==='setup')renderSections(true);   drawDeps(); syncPlanSpan(); syncCollapseAllBtn(); syncPlanCtl(); if(critOn())refreshCritical();+  keepPlace(mark);   if(edSubject)syncEditorDates(edSubject);   lastSnap=snapSemantic();   if(typeof st.stateRev==='number')baseRev=st.stateRev;@@ -5134,9 +5148,11 @@ function pruneTagFilter(){ // Everything a change in what is shown has to move: the rows, the arrows, the caps, the // table, the legend's own chips. The same tail Collapse all runs. function applyTagView(){+  const mark=placeMark();   rebuildLegend();   applyFilter();   layoutMsCaps();sizeTailroom();drawDeps();+  keepPlace(mark);   depHoverResync(); } function setTagFilter(list){@@ -5673,6 +5689,49 @@ function syncLabelScroll(){   if(timelineEl.scrollTop>maxL)timelineEl.scrollTop=maxL;   labelsEl.scrollTop=timelineEl.scrollTop; }+// ── Holding the reader's place (0.74.1) ──+// A change in which rows show (a filter, Collapse all) keeps the row the reader is+// looking at where it was on screen. When the change hides that row, the nearest row+// above it that still shows takes its spot, which after a Collapse all is its own+// section's header, so the reader stays in the section they were in. Both panes move+// together, through the timeline. The browser's own scroll anchoring is off in both+// (CSS): it anchored each pane by itself, and picking Unassigned while scrolled down+// moved the names and not the bars, leaving them 1,150 px apart until the next scroll.+// A pinned header reports where it is pinned, not where it lives (see capBandBase), so+// the headers' sticky is lifted for the read, all at once, and put back in the same tick+// before any paint.+function unpinnedRead(fn){+  bars.classList.add('unpinned');+  try{ return fn(); }finally{ bars.classList.remove('unpinned'); }+}+function placeMark(){+  if(prefs.view!=='gantt')return null;+  // Read before the lift: the band ends under the header that is pinned now.+  const paneTop=timelineEl.getBoundingClientRect().top, band=chartViewBand().top;+  return unpinnedRead(()=>{+    for(let i=0;i<items.length;i++){+      const r=items[i];+      if(!r._bDiv||r._collapsed)continue;+      const b=r._bDiv.getBoundingClientRect();+      if(b.bottom>band+1)return {r,i,y:b.top-paneTop};+    }+    return null;+  });+}+function keepPlace(mark){+  if(prefs.view!=='gantt')return;+  const shows=x=>x._bDiv&&!x._collapsed;+  // A row another person deleted is gone from items: its old index stands in for it.+  let i=mark?items.indexOf(mark.r):-1;+  const gone=mark&&i<0;+  if(gone)i=Math.min(mark.i,items.length);+  const r=!mark?null:!gone&&shows(mark.r)?mark.r:(items.slice(0,i).reverse().find(shows)||items.slice(gone?i:i+1).find(shows));+  if(r){+    const paneTop=timelineEl.getBoundingClientRect().top;+    timelineEl.scrollTop+=unpinnedRead(()=>r._bDiv.getBoundingClientRect().top)-paneTop-mark.y;+  }+  syncLabelScroll(); updatePinnedHeader();+} function mountLabelTailroom(){ labelTail.className='tailroom';labelsEl.appendChild(labelTail); timelineEl.addEventListener('scroll',()=>{syncLabelScroll();updatePinnedHeader();});@@ -5688,7 +5747,9 @@ function updatePinnedHeader(){   const top=timelineEl.scrollTop;   const groups=items.filter(isDerivedGroup);   let active=null;-  groups.forEach(g=>{ if(g._lDiv && (g._lDiv.offsetTop-top)<=axis+0.5) active=g; });+  // A header a filter hides reads offsetTop 0, which is "above the axis" at any scroll,+  // so it would win the pin while not on screen (0.74.1).+  groups.forEach(g=>{ if(g._lDiv && !g._collapsed && (g._lDiv.offsetTop-top)<=axis+0.5) active=g; });   if(active!==pinnedGroup){     groups.forEach(g=>{ const on=g===active;       if(g._lDiv)g._lDiv.classList.toggle('is-pinned',on);@@ -5994,7 +6055,10 @@ function applyFilter(){applyCollapse();items.forEach(r=>{let vis=true;if(r.phase   // to it by hand or a milestone filtered out by phaseVis (or the global Milestone   // toggle) would leave its tag at full strength in the top band. Collapse is handled   // separately: applyCollapse hides _msLine outright with .row-collapsed.-  if(r._msLine)r._msLine.classList.toggle('ms-dim',!vis);});drawDeps();if(prefs.view==='table')renderTable();}+  if(r._msLine)r._msLine.classList.toggle('ms-dim',!vis);});drawDeps();+  // Rows came or went: the two panes are one scroll, so they settle together (0.74.1).+  if(prefs.view==='gantt'){ syncLabelScroll(); updatePinnedHeader(); }+  if(prefs.view==='table')renderTable();}  // ── Dependencies ── function sb0(entry){ return entry.row || entry.bar.closest('.bar-row'); }@@ -6388,6 +6452,10 @@ function paintCancelledDeps(){ } function drawDeps(){   depSvg.innerHTML='';let maxY=0;+  // 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();   items.forEach(r=>{if(!r.deps||!r.id)return;const tgt=barEls[r.id];if(!tgt)return;
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.74.0",+  "version": "0.74.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.74.0",+  "version": "0.74.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": {
test/ui_test.mjs+125−19
@@ -184,8 +184,8 @@ async function expectReadouts(page, today, what) { // ── Server ── const servers = []; // The suite's ports are a FIXED, bounded range, so parallel agents and other tools can be-// given ports outside it safely: 8960-8979 by default (a full run starts about fifteen-// servers), or UI_TEST_PORTS=<lo>-<hi>. Until 0.59.0's review the probe walked upward from+// given ports outside it safely: 8960-8979 by default (a full run starts about two dozen+// servers, a dozen at most up at once), or UI_TEST_PORTS=<lo>-<hi>. Until 0.59.0's review the probe walked upward from // 8960 with no real bound, and a reviewer's own server on 8972 was driven by a run. // Each call scans the whole range for a port this run has not handed out and nothing is // listening on, and it fails loudly when there is none rather than wandering outside.@@ -221,6 +221,23 @@ async function startServer(plan, name) {   servers.push(s);   return s; }+// A dedicated flow is done with the servers it started once it returns: they stop and+// their ports go back to the range, so a run needs as many ports as are up at once, not+// as many as it ever started (0.74.1: 24 servers met the 20-port range and the run died).+async function stopServer(s) {+  const i = servers.indexOf(s); if (i >= 0) servers.splice(i, 1);+  if (s.proc.exitCode === null && s.proc.signalCode === null) {+    const gone = new Promise(r => s.proc.once('exit', r));+    s.proc.kill('SIGTERM');+    await Promise.race([gone, sleep(3000)]);+  }+  portsGiven.delete(s.port);+}+async function ownServers(flow, browser) {+  const before = servers.length;+  try { await flow(browser); }+  finally { for (const s of servers.slice(before)) await stopServer(s); }+}  // ── Pages: error gate, pinned clock, input modality ── const pageErrors = [];@@ -464,7 +481,7 @@ async function main() {       section = 'live: undo/redo';       await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);       r = await expectReadouts(page, TODAY, 'undo');-      check('undo restores 16w 4d', r.dur === base.dur, r.dur);+      check('undo restores the duration', r.dur === base.dur, r.dur);       await page.keyboard.down('Control'); await page.keyboard.down('Shift'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Shift'); await page.keyboard.up('Control'); await settle(page);       r = await expectReadouts(page, TODAY, 'redo');       check('redo brings the dragged end back', r.dur === afterDrag.dur, r.dur);@@ -478,7 +495,7 @@ async function main() {       check('Auto-schedule moved the end of the plan', r.last !== LAST, r.last);       await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);       r = await expectReadouts(page, TODAY, 'undo of Auto-schedule');-      check('undo of Auto-schedule restores 16w 4d', r.dur === base.dur, r.dur);+      check('undo of Auto-schedule restores the duration', r.dur === base.dur, r.dur);        // Table edit of the last row's deadline.       section = 'live: table edit';@@ -524,7 +541,7 @@ async function main() {       check('deleting the last row shortened the plan', r.last < LAST, r.last);       await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);       r = await expectReadouts(page, TODAY, 'undo of the delete');-      check('undo of the delete restores 16w 4d', r.dur === base.dur, r.dur);+      check('undo of the delete restores the duration', r.dur === base.dur, r.dur);        // Paste: copy the last row and paste it; the copy has the same dates, so the span       // is unchanged, and the readouts must still agree with the plan.@@ -552,7 +569,7 @@ async function main() {       check('the new task moved the end to 29-Jan-2027', r.last === '2027-01-29', r.last);       await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page, 300);       r = await expectReadouts(page, TODAY, 'undo of the create');-      check('undo of the create restores 16w 4d', r.dur === base.dur, r.dur);+      check('undo of the create restores the duration', r.dur === base.dur, r.dur);       await page.close();     } @@ -649,7 +666,8 @@ async function main() {       await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page);       check('a stored array does not swallow the size (110% after A+ from 100%)', await fsPct(page) === '110%', await fsPct(page));       const snapR = await readouts(page);-      check('the snapshot shows the same readouts', snapR.dur === '16w 4d' && snapR.left === '15w 3d' && snapR.start === '21-Sep-2026', JSON.stringify(snapR));+      // The seeded plan runs 21-Sep-2026 to 14-Jan-2027; the oracle counts the workdays.+      check('the snapshot shows the same readouts', snapR.dur === spanText('2026-09-21', '2027-01-14') && snapR.left === spanText(TODAY, '2027-01-14') && snapR.start === '21-Sep-2026', JSON.stringify(snapR));       await page.close();        // Cause 3 (0.58.0): the wiki serves an uploaded snapshot under a CSP sandbox with@@ -806,7 +824,7 @@ async function main() {       page = await openPage(browser, MO.url);       r = await readouts(page);       check('year-month data: the start button still shows a day (01-Oct-2026)', r.start === '01-Oct-2026', r.start);-      check('year-month data: duration runs 01-Oct to 30-Nov (61 days = 8w 5d)', r.dur === '8w 5d' && /01-Oct-2026/.test(r.durTip) && /30-Nov-2026/.test(r.durTip), `${r.dur} ${r.durTip}`);+      check(`year-month data: duration runs 01-Oct to 30-Nov (61 days = ${spanText('2026-10-01', '2026-11-30')})`, r.dur === spanText('2026-10-01', '2026-11-30') && /01-Oct-2026/.test(r.durTip) && /30-Nov-2026/.test(r.durTip), `${r.dur} ${r.durTip}`);       await page.close();     } @@ -841,17 +859,18 @@ async function main() {     }     if (wantAny('editor', 'new item fields', 'table filters')) await editorFlows(browser, S);     if (wantAny('export menu', 'import menu')) await menuFlows(browser, S);-    if (wantAny('sections')) await sectionsFlow(browser);-    if (wantAny('tags')) await tagsFlow(browser);-    if (wantAny('row tags')) await rowTagsFlow(browser);-    if (wantAny('effort')) await effortFlow(browser);-    if (wantAny('collab')) await collabFlow(browser);-    if (wantAny('notify')) await notifyFlow(browser);-    if (wantAny('share')) await shareFlow(browser);-    if (wantAny('provenance')) await provFlow(browser);-    if (wantAny('pick by mouse')) await pickFlow(browser);-    if (wantAny('type ahead')) await typeAheadFlow(browser);-    if (wantAny('row links')) await rowLinksFlow(browser);+    if (wantAny('sections')) await ownServers(sectionsFlow, browser);+    if (wantAny('tags')) await ownServers(tagsFlow, browser);+    if (wantAny('row tags')) await ownServers(rowTagsFlow, browser);+    if (wantAny('effort')) await ownServers(effortFlow, browser);+    if (wantAny('collab')) await ownServers(collabFlow, browser);+    if (wantAny('notify')) await ownServers(notifyFlow, browser);+    if (wantAny('share')) await ownServers(shareFlow, browser);+    if (wantAny('provenance')) await ownServers(provFlow, browser);+    if (wantAny('pick by mouse')) await ownServers(pickFlow, browser);+    if (wantAny('type ahead')) await ownServers(typeAheadFlow, browser);+    if (wantAny('row links')) await ownServers(rowLinksFlow, browser);+    if (wantAny('scroll sync')) await ownServers(scrollSyncFlow, browser);      if (wantAny('viewport')) {       // ── Viewport matrix ──@@ -3577,6 +3596,93 @@ async function shareFlow(browser) {   await page.close(); } +// 0.74.1: picking Unassigned while scrolled down moved the names 1,150 px and not the+// bars, and they stayed apart until the next scroll. A filter or a fold keeps the two+// panes row for row, and the row the reader is looking at stays where it was. Thirty+// sections so even the folded list scrolls; nobody in the last is unassigned, so the+// filter hides its header too (a hidden header used to win the pin).+async function scrollSyncFlow(browser) {+  section = 'scroll sync';+  const owners = ['Ana', 'Bo', ''], phases = [], items = [];+  for (let p = 0; p < 30; p++) {+    phases.push({ name: `Lane ${p + 1}`, start: '2026-10', end: '2026-11' });+    for (let k = 0; k < 5; k++) {+      const d = 1 + (p * 3 + k) % 20;+      items.push({ type: 'task', id: `s${p}k${k}`, name: `Lane ${p + 1} task ${k + 1}`, phase: p, owner: p === 29 ? 'Ana' : owners[(p + k) % 3],+        start: `2026-10-${String(d).padStart(2, '0')}`, end: `2026-10-${String(d + 5).padStart(2, '0')}`, ...(k ? { deps: [`s${p}k${k - 1}`] } : {}) });+    }+  }+  const X = await startServer({ project: { title: 'Scroll Sync', appName: 'scroll-sync', view: { mode: 'gantt', grain: 'week' } }, phases, items }, 'scroll-sync');+  const page = await openPage(browser, X.url);+  // Row for row: the n-th name and the n-th bar share a top, and the panes a scrollTop.+  // `at` is where each row and header is, in px from the top of the chart pane.+  const look = () => page.evaluate(() => {+    const L = document.getElementById('labels'), T = document.getElementById('timeline');+    const ls = [...L.querySelectorAll('.label-row')], bs = [...T.querySelectorAll('.bar-row')];+    let worst = 0;+    ls.forEach((l, i) => { if (bs[i] && l.offsetHeight) worst = Math.max(worst, Math.abs(bs[i].getBoundingClientRect().top - l.getBoundingClientRect().top)); });+    const pt = T.getBoundingClientRect().top, at = {};+    ls.forEach(l => { const n = l.querySelector('.lane-title, .name-text'); if (n && l.offsetHeight) at[n.textContent.trim()] = Math.round(l.getBoundingClientRect().top - pt); });+    const pin = L.querySelector('.label-row.group.is-pinned');+    return { worst: Math.round(worst), l: L.scrollTop, t: T.scrollTop, at,+      over: (T.scrollHeight - T.clientHeight) - (L.scrollHeight - L.clientHeight),+      pin: !pin ? '' : pin.offsetHeight ? pin.querySelector('.lane-title').textContent.trim() : 'hidden',+      band: pin && pin.offsetHeight ? Math.round(pin.getBoundingClientRect().bottom - pt) : 0 };+  });+  // Scroll the named row up to just under the pinned header: the first row in view.+  const putAtTop = async name => {+    for (let i = 0; i < 4; i++) {+      const v = await look(), d = v.at[name] - v.band;+      if (v.band && !d) return v;+      await page.evaluate(d => { document.getElementById('timeline').scrollTop += d; }, d);+      await settle(page, 150);+    }+    return look();+  };+  const owner = async v => { await page.select('#ganttOwner', v); await settle(page, 250); };+  // Lane 15: tasks 1 and 4 are nobody's, task 2 is Ana's, task 3 is Bo's.+  const R = 'Lane 15 task 4', Q = 'Lane 15 task 3', P = 'Lane 15 task 1';+  let v = await putAtTop(R);+  const y = v.at[R];+  check('a row sits first in view, under its pinned section', Number.isFinite(y) && y === v.band && v.pin === 'Lane 15' && v.t > 1500, JSON.stringify({ y, band: v.band, pin: v.pin, t: v.t }));+  await owner('__unassigned__');+  v = await look();+  check('Unassigned while scrolled down: the names and the bars stay row for row', v.worst === 0 && v.l === v.t, JSON.stringify({ worst: v.worst, l: v.l, t: v.t }));+  check('the chart cannot scroll past the names (the arrow layer shrinks with the rows)', v.over <= 40, `chart reaches ${v.over}px further`);+  check('the row in view stays where it was', v.at[R] === y, `${v.at[R]} vs ${y}`);+  check('the pinned header is one on screen (a hidden section does not win it)', v.pin === 'Lane 15', v.pin);+  await owner('');+  v = await look();+  check('All owners again: row for row, the same row in the same place', v.worst === 0 && v.l === v.t && v.at[R] === y, JSON.stringify({ worst: v.worst, l: v.l, t: v.t, at: v.at[R], y }));+  v = await putAtTop(Q);+  const yq = v.at[Q];+  check('the next row sits first in view', Number.isFinite(yq) && yq === v.band, JSON.stringify({ yq, band: v.band }));+  await owner('__unassigned__');+  v = await look();+  check('a filter that hides the row in view puts the nearest shown row above it in its place', v.worst === 0 && v.at[Q] === undefined && v.at[P] === yq, JSON.stringify({ worst: v.worst, Q: v.at[Q], P: v.at[P], yq }));+  await owner('');+  v = await putAtTop(R);+  await page.click('#btnCollapseAll'); await settle(page, 250);+  v = await look();+  check('Collapse all: row for row, and the section in view takes its row\'s place', v.worst === 0 && v.l === v.t && v.at['Lane 15'] === y, JSON.stringify({ worst: v.worst, l: v.l, t: v.t, lane: v.at['Lane 15'], y }));+  await page.click('#btnCollapseAll'); await settle(page, 250);+  v = await look();+  check('Expand all: row for row, and that section stays put', v.worst === 0 && v.l === v.t && v.at['Lane 15'] === y && v.at[P] > y, JSON.stringify({ worst: v.worst, l: v.l, t: v.t, lane: v.at['Lane 15'], first: v.at[P], y }));+  // Another tab deletes the plan's first row, far above this tab's view. The live merge+  // takes it away here too, and the row this tab is looking at does not move.+  v = await putAtTop(R);+  const y2 = v.at[R];+  const B = await openPage(browser, X.url, { clear: false });+  await clickBar(B, 's0k0'); await settle(B, 200);+  await B.click('#imDelete'); await settle(B, 300);+  let gone = false;+  for (const t0 = Date.now(); !gone && Date.now() - t0 < 6000;) { await sleep(150); v = await look(); gone = v.at['Lane 1 task 1'] === undefined; }+  check('a row another tab deletes far above goes here too, live', gone && Number.isFinite(y2), JSON.stringify({ y2 }));+  check('and this tab\'s row stays where it was, the panes row for row', v.worst === 0 && v.l === v.t && v.at[R] === y2, JSON.stringify({ worst: v.worst, l: v.l, t: v.t, at: v.at[R], y2 }));+  await B.close();+  await page.close();+}+ function cleanupSync() {   if (cleanedUp) return; cleanedUp = true;   for (const s of servers) { try { s.proc.kill('SIGKILL'); } catch {} }