← Commit history

0.34.0: phase-scoped milestones (both lenses), cap band tracks the top section, name/bar panes lock on overscroll, name text is its own click target

Drew Owens ·a9d1d7d1d6 · ·parent ab0d5b7
4 files changed +202−27
docs/BUILD-NOTES.md+81
@@ -3,6 +3,87 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-17 (0.34.0, milestones are phase-scoped, caps track the top, panes lock, the name text is its own hit target)++Four changes, all in the same scroll and geometry subsystem, shipped together.++**A. Milestones belong to their phase, in both lenses.** A milestone already carried+a `phase` and `applyCollapse` already set `_collapsed` on it, so a collapsed lane+already dropped its caps off the CHART. Two gaps remained. The TABLE never honoured+collapse at all: `tableRows` filtered on `phaseVis` and the global `msVis` but not on+the collapse set, so a collapsed lane's tasks AND milestones stayed in the list. And+in the chart a `phaseVis` filter (or the global Milestone toggle) washed a task bar to+`.dim` opacity 0.15 but left the line-mode cap at full strength, because the cap lives+on `_msLine`, not on the `_bDiv` the filter dimmed. Fixes: `tableRows` now skips any+row whose phase `isCollapsed`, so collapse empties the same rows from the table it+empties from the chart, milestones included; and `applyFilter` now toggles a new+`.ms-dim` class on `_msLine` alongside the `.dim` on the bar, with `.ms-line.ms-dim+.ms-cap` and `.ms-line.ms-dim::before` at opacity 0.15. The opacity goes on the cap+and the dash, never on `.ms-line` itself, for the stacking-context reason the 0.30-era+comment there already records. Net: collapse HIDES a milestone (cap and table row),+`phaseVis`/global-toggle DIM its cap, exactly the two treatments a task in that phase+gets, and the two lenses agree.++**B. The caps band tracks the topmost section that actually shows content.**+`capBandBase` used to read the FIRST derived group header's height and pin the band+just below it. When the top section(s) were collapsed or empty their headers stacked+up with no rows under them, and the band pinned below the first header ended up tucked+behind the second or third. New `capBandAnchor` walks the group headers and returns+the first one that is neither collapsed nor empty (skipping any leading run of+collapsed/empty lanes); `capBandBase` returns that header's `offsetTop+offsetHeight+4`+in the bars pane's own coordinates. At rest the anchor is the top section and the base+is unchanged; collapse the top and the band travels DOWN to below the first lane with+visible rows, so a remaining cap is never left behind a header. The today tag shares+the band and is moved to the same base (in both milestone styles), so the two never+split. `phaseVis` is deliberately ignored by the anchor: a filtered lane is only+washed out, its rows still occupy their space, so the geometry must not move for it.++**C. The name pane and the bar pane are locked in vertical alignment, past the end.**+The timeline is the real scroller and the label column mirrors its `scrollTop`. The+timeline can be marginally taller (a dependency arrow's SVG canvas reaches a little+below the last row), so an overscroll there slid the bars past a point the labels+could not follow and the rows drifted. `syncLabelScroll` now clamps the timeline to+the labels' own max scroll before mirroring, so `labelsEl.scrollTop` always equals+`timelineEl.scrollTop` and the rows stay locked at and past the end, whatever the+gesture (wheel, drag, PageDown/End). It replaces the bare mirror at both the scroll+listener and the wheel handler. `overscroll-behavior: contain` on `.timeline-col`+handles the other half the mirror cannot see: a trackpad rubber-band is a paint+transform, not a `scrollTop` change, so without it the bars bounce while the+overflow:hidden label column holds still. Contained, the timeline stops dead at its+limit.++**D. The name text is its own hit target.** The name of a task or milestone is now+wrapped in a `.name-text` span (one `nameInner` helper feeds the build loop, the+inline-edit restore and the table-to-chart status sync, so the name is never drawn+three subtly different ways). A plain click whose target is the glyphs opens the+inline editor; a plain click in the empty space after them (the gap past the status+pill) selects the row instead, the same single-select clicking the bar or the+milestone line does. A lane header still edits its phase name on a plain click. The+status pill (0.32.0) is unchanged: its branch runs before the name logic. `.name-text`+carries a `cursor: text` and, on hover, a subtle grey inset outline+(`box-shadow: inset 0 0 0 1px var(--border)`) as the "this is editable" affordance,+gone the moment the pointer leaves. In line mode a milestone's own left label row is+`display:none`, so D applies to task rows and to milestones in diamond mode.++**Verified headless, both lenses, zero JS errors.** A new suite, `msphase`, exercises+all four plus a milestone reparent: collapse a lane with milestones and its caps AND+table rows vanish and return on expand; `phaseVis`/global-toggle dim the caps to 0.15+in the chart and drop the rows from the table; the caps repositioned from tops+[160,180,160] to [216,236,216] below the anchor when the top lane was collapsed (an+empty lane above them skipped too), none clipped, and back to base on expand; every+overscroll path leaves `labelsEl.scrollTop===timelineEl.scrollTop` with zero row+delta; the name glyphs edit, the gap selects, the pill still opens its list, the hover+rule is present; and a milestone moved from Alpha into Charlie via the table recolours+to Charlie's blue, matches its new lane-mate M2, and then hides when Charlie collapses+while M4 in Delta is untouched. The suite fails on 0.33.0 exactly where the new+behaviour lives (table rows still present on collapse, cap opacity 1 under filter,+caps do not move, no `.name-text` to click), which is the argument that it tests the+change. The full existing battery — kb, cols, deps, uid, mssel, memo, dblist, mscap,+unify, sections, secmodal, sticky2, pill (statuspill) and the persist+(phase-persistence) suite — was run against a reconstructed pristine 0.33.0 and this+0.34.0 on the same fixture and every one is byte-identical between the two, zero JS+errors on both, which is the argument that nothing else moved.+ ## Build notes, 2026-08-17 (0.33.0, the client persists the section list on load)  **The bug.** The client's `applyState` never applied the sidecar's full section
gantt.html+118−24
@@ -205,7 +205,13 @@ header #btnTheme { min-width: 56px; justify-content: center; } .col-resizer::after { content: ''; position: absolute; inset: 0 3px; background: transparent; transition: background 0.15s; } .col-resizer:hover::after, .col-resizer.dragging::after { background: var(--accent); } body.col-resizing { cursor: col-resize; user-select: none; }-.timeline-col { flex: 1; overflow: auto; scrollbar-width: none; }+/* overscroll-behavior contains the rubber-band at the ends: the bounce is a paint+   transform the scrollTop mirror cannot see, so without this a trackpad overscroll+   past the bottom springs the bars while the label column (overflow:hidden) holds+   still, and the rows visibly drift apart. Contained, the timeline stops dead at its+   limit and stays locked to the names. The JS clamp in syncLabelScroll handles the+   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; } .tailroom { flex-shrink: 0; pointer-events: none; } @media print { .tailroom { display: none; } }@@ -227,6 +233,12 @@ body.col-resizing { cursor: col-resize; user-select: none; } .tag { display: inline-block; font-family: 'JetBrains Mono', monospace; font-size: 9px; padding: 1px 5px; border-radius: 4px; margin-left: 6px; vertical-align: middle; } .label-row .status-pill { cursor: pointer; } .label-row .status-pill:hover { box-shadow: inset 0 0 0 1px currentColor; filter: brightness(1.12); }+/* The name text is its own hit target now: only the glyphs open the editor, and the+   empty space after them selects the row. The margin cancels the padding so the box+   hugs the text without nudging the row sideways; the hover outline is the affordance+   that says "this is editable", subtle and grey, gone the moment the pointer leaves. */+.label-row:not(.group) .name-text { cursor: text; border-radius: 3px; padding: 1px 3px; margin: 0 -3px; }+.label-row:not(.group) .name-text:hover { box-shadow: inset 0 0 0 1px var(--border); } .tag-not-started { background: rgba(139,148,158,0.10); color: var(--text2); } .tag-in-progress { background: rgba(0,184,177,0.14); color: var(--accent); } .tag-blocked { background: rgba(210,153,34,0.15); color: var(--warn); }@@ -267,6 +279,13 @@ body.col-resizing { cursor: col-resize; user-select: none; } .ms-line::before { content: ''; position: absolute; top: 0; bottom: 0; left: 0; border-left: 1.5px dashed currentColor; opacity: 0.75; z-index: 4; } .ms-line .ms-cap:hover { background: var(--elevated); filter: brightness(1.25); } .ms-line .ms-cap { position: absolute; z-index: 7; opacity: 1; top: 2px; left: 0; transform: translateX(-50%); pointer-events: auto; cursor: pointer; font-family: 'JetBrains Mono', monospace; font-size: 9px; line-height: 1.25; color: inherit; background: var(--bg); padding: 2px 4px; border-radius: 3px; border: 1px solid currentColor; white-space: normal; overflow-wrap: anywhere; text-align: center; max-width: 132px; width: max-content; }+/* A milestone whose phase is filtered out (phaseVis) or hidden by the global+   Milestone toggle washes out like a filtered task bar (.dim, opacity 0.15). The+   opacity goes on the cap and the dashed rule, never on .ms-line itself, for the+   stacking reason spelled out above. A COLLAPSED phase hides its milestone outright+   via .row-collapsed; this is only the dim state. */+.ms-line.ms-dim .ms-cap { opacity: 0.15; }+.ms-line.ms-dim::before { opacity: 0.15; } :root[data-ms="diamond"] .ms-line { display: none; } :root[data-ms="line"] .ms-diamond { display: none; } /* In line style the dashed rule and its tag carry the milestone, so the grid row is@@ -2489,6 +2508,19 @@ function makeEditBtn(r){   return b; } +// The inner HTML of a task or milestone name span: the name wrapped in its own+// .name-text hit target, plus the status pill for a task. One place builds it so the+// build loop, the inline-edit restore and the table-to-chart status sync can never+// draw the name three subtly different ways. Groups are not routed through here; a+// lane header keeps its own .lane-name.+function nameInner(r){+  const nt='<span class="name-text">'+esc(r.name||'')+'</span>';+  if(r.type==='milestone')return nt;+  const sk=statusOf(r);+  const tg=sk?`<span class="tag status-pill ${statusTagFor(sk)}" title="Click to change status">${esc(statusTextFor(sk))}</span>`:'';+  return nt+tg;+}+ // Extracted from the initial build loop so a row created at runtime is built by // exactly the same code as a row from the data file, rather than a second // almost-identical path that drifts.@@ -2511,7 +2543,7 @@ function buildRowDom(r,idx){     lDiv.appendChild(Object.assign(document.createElement('span'),{className:'row-name'}));     lDiv.appendChild(makeEditBtn(r));     r._nameSpan=lDiv.querySelector('.row-name');-    r._nameSpan.textContent=r.name;+    r._nameSpan.innerHTML=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';@@ -2533,8 +2565,7 @@ function buildRowDom(r,idx){     lDiv.appendChild(Object.assign(document.createElement('span'),{className:'row-name'}));     lDiv.appendChild(makeEditBtn(r));     r._nameSpan=lDiv.querySelector('.row-name');-    const sk=statusOf(r);const tg=sk?`<span class="tag status-pill ${statusTagFor(sk)}" title="Click to change status">${esc(statusTextFor(sk))}</span>`:'';-    r._nameSpan.innerHTML=esc(r.name)+tg;+    r._nameSpan.innerHTML=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.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};}@@ -2577,9 +2608,23 @@ function mountBarTailroom(){ barTail.className='tailroom';timelineEl.appendChild(barTail); } const labelTail=document.createElement('div');+// The names and the bars are one scroll: the timeline is the real scroller and the+// label column mirrors its scrollTop. The timeline can be TALLER than the labels,+// though, because a dependency arrow's SVG canvas can reach a little below the last+// row, so an overscroll on the timeline (wheel, drag, PageDown/End, past the end)+// would slide the bars down past the point the labels can follow and the rows would+// drift out of alignment. Clamp the timeline to the distance the labels can actually+// travel first, then mirror, so the two panes are locked row-for-row at the end of+// the list and past it. Setting scrollTop here re-fires scroll, but the clamp is+// idempotent once reached, so it settles in one extra tick rather than looping.+function syncLabelScroll(){+  const maxL=labelsEl.scrollHeight-labelsEl.clientHeight;+  if(timelineEl.scrollTop>maxL)timelineEl.scrollTop=maxL;+  labelsEl.scrollTop=timelineEl.scrollTop;+} function mountLabelTailroom(){ labelTail.className='tailroom';labelsEl.appendChild(labelTail);-timelineEl.addEventListener('scroll',()=>{labelsEl.scrollTop=timelineEl.scrollTop;updatePinnedHeader();});+timelineEl.addEventListener('scroll',()=>{syncLabelScroll();updatePinnedHeader();}); } // Which section header is currently stuck under the axis. Pure CSS does the // pinning and the swap; this only adds the class the drop-shadow hangs on, so a@@ -2638,8 +2683,9 @@ function wireChartWheel(){     // Sync the label column HERE rather than lean on the timeline's own scroll     // event: a programmatic scrollTop fires that event late (or not at all in some     // engines), so the labels have to be pulled along in the same tick the wheel is-    // handled or they lag a frame behind the bars.-    labelsEl.scrollTop=timelineEl.scrollTop;+    // handled or they lag a frame behind the bars. syncLabelScroll also clamps the+    // overscroll so the bars can never outrun the names past the end of the list.+    syncLabelScroll();     updatePinnedHeader();     // Own the gesture: the label column has no scroll of its own, and the page     // behind must never rubber-band because the pointer was over a header.@@ -2690,16 +2736,40 @@ function relayout(){ // band the milestone caps live in. So the caps band starts BELOW it: at rest, a // cap under the first section's header is no longer hidden by it, and a cap that // scrolls up still slides under the header (it is z-below) rather than being-// clipped in place. `base` is the header height plus a hair, read from a real-// group row so it tracks the font scale.+// clipped in place.+//+// The band anchors to the FIRST section that actually shows content, not blindly to+// the first header (0.34.0). When the top section(s) are collapsed or empty their+// headers stack up with nothing under them, and a band pinned below the first header+// would be tucked behind the second or third one. So the anchor skips any leading+// collapsed or empty section and lands on the topmost expanded, non-empty one; the+// band sits just below THAT header and travels down as the user collapses the top of+// the plan. phaseVis is deliberately ignored: a filtered section is only washed out,+// its rows still occupy their space, so the geometry does not move for it.+function capBandAnchor(){+  const groups=items.filter(isDerivedGroup);+  for(const g of groups){+    if(isCollapsed(g.phase))continue;+    const has=items.some(r=>r.type!=='group'&&r.phase===g.phase&&!isCollapsed(r.phase));+    if(has)return g;+  }+  return groups[0]||null;+}+// Content-y (relative to the bars pane, where the caps and today tag are absolutely+// positioned) just below the anchor header. It tracks the font scale because it is+// read from a real row rather than a constant. function capBandBase(){-  const g=items.find(isDerivedGroup);-  const h=(g&&g._bDiv&&g._bDiv.offsetHeight)|| (28*(prefs.fs||1));-  return Math.round(h)+4;+  const g=capBandAnchor();+  if(g&&g._bDiv)return Math.round(g._bDiv.offsetTop+g._bDiv.offsetHeight)+4;+  return Math.round(28*(prefs.fs||1))+4; } function layoutMsCaps(){-  if(prefs.msStyle!=='line')return;   const base=capBandBase();+  // The today tag shares the band, so it rides the anchor too, in both milestone+  // styles. Keeping it level with the caps means a collapsed top section can never+  // strand it behind a header either.+  if(todayTagEl)todayTagEl.style.top=base+'px';+  if(prefs.msStyle!=='line')return;   const caps=[];   items.filter(r=>r.type==='milestone'&&r._msLine&&!r._collapsed).forEach(r=>{     const cap=r._msLine.querySelector('.ms-cap');@@ -2788,7 +2858,12 @@ if(document.fonts&&document.fonts.ready)document.fonts.ready.then(()=>{if(prefs. // goes through (add, delete, restore, phase move, drag, keyboard move), so putting // it here covers all of them at once instead of at seven call sites, one of which a // later round would have forgotten.-function applyFilter(){applyCollapse();items.forEach(r=>{let vis=true;if(r.phase!==undefined&&!phaseVis[r.phase])vis=false;if(r.type==='milestone'&&!msVis)vis=false;r._lDiv.classList.toggle('dim',!vis);r._bDiv.classList.toggle('dim',!vis);});drawDeps();if(prefs.view==='table')renderTable();}+function applyFilter(){applyCollapse();items.forEach(r=>{let vis=true;if(r.phase!==undefined&&!phaseVis[r.phase])vis=false;if(r.type==='milestone'&&!msVis)vis=false;r._lDiv.classList.toggle('dim',!vis);r._bDiv.classList.toggle('dim',!vis);+  // The line-mode cap lives on _msLine, not _bDiv, so the dim wash has to be carried+  // 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();}  // ── Dependencies ── function sb0(entry){ return entry.row || entry.bar.closest('.bar-row'); }@@ -2976,11 +3051,27 @@ labelsEl.addEventListener('click',e=>{     if(selected.size>1)openBatchEditor(); else closeEditor();     return;   }-  // A plain click is a name edit (0.14.0), and it also collapses any multi-select,-  // the way clicking one item in any list drops the rest. Building the set stays a-  // ctrl or shift gesture; nothing without a modifier ever ADDS to it.-  if(selected.size)clearSelection();-  editLabelInline(r);+  // A plain click's target inside the name row is now hit-tested (0.34.0). A lane+  // header still edits its phase name on a plain click, the way it always has. On a+  // task or milestone row the glyphs of the name open the editor; the empty space+  // after them (the gap past the status pill) selects the row instead, the same+  // single-select clicking the bar or the milestone line would do. A plain click on+  // the name text also collapses any multi-select, the way clicking one item in any+  // list drops the rest; building the set stays a ctrl or shift gesture.+  if(isDerivedGroup(r)){ if(selected.size)clearSelection(); editLabelInline(r); return; }+  if(e.target.closest('.name-text')){+    if(selected.size)clearSelection();+    editLabelInline(r);+    return;+  }+  // The gap: select the row. selUid is null for anything that cannot be selected, so+  // this is inert on a header. stopPropagation for the same editor-guard reason as+  // the modified-click branch above.+  if(!selUid(r))return;+  e.stopPropagation();+  closeLabelEditors(true);+  doSelect(r.uid,e);+  if(selected.size>1)openBatchEditor(); else closeEditor(); }); // The second click of a double-click upgrades to the full editor, carrying // whatever has been typed so far, so the fast path is never paid for by the@@ -3606,7 +3697,7 @@ function snapshotMs(r){ // dashed rule's tag all restate the milestone, so they move together. function refreshMsChrome(r){   if(!r)return;-  if(r._nameSpan)r._nameSpan.textContent=r.name;+  if(r._nameSpan)r._nameSpan.innerHTML=nameInner(r);   else if(r._lDiv)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);}   if(r._msLine){const cap=r._msLine.querySelector('.ms-cap');@@ -4784,11 +4875,9 @@ function rowItem(key){ // already true on the chart when you toggle back. function refreshLabelRow(r){   if(!r||!r._lDiv||!r.id)return;-  const sk=statusOf(r);-  const tg=sk?`<span class="tag status-pill ${statusTagFor(sk)}" title="Click to change status">${esc(statusTextFor(sk))}</span>`:'';   // into the name span only: rewriting the row would take the edit button with it-  if(r._nameSpan)r._nameSpan.innerHTML=esc(r.name)+tg;-  else r._lDiv.innerHTML=esc(r.name)+tg;+  if(r._nameSpan)r._nameSpan.innerHTML=nameInner(r);+  else r._lDiv.innerHTML=nameInner(r); }  function distinctOwners(){@@ -4829,6 +4918,11 @@ function tableRows(){   const rows=[];   items.forEach(r=>{     if(isGroup(r))return;+    // A collapsed phase hides its rows in the table too, so the two lenses agree: a+    // milestone (and every task) in a collapsed lane drops out of the list exactly as+    // its caps and bars drop off the chart. isCollapsed is read straight off the set+    // rather than the per-row _collapsed flag, so it is right even before a reflow.+    if(r.phase!==undefined&&isCollapsed(r.phase))return;     if(r.phase!==undefined&&!phaseVis[r.phase])return;     if(isMs(r)&&!msVis)return;     const o=ownerOf(r);
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.33.0",+  "version": "0.34.0",   "description": "Interactive Gantt chart viewer for project timelines, with a table view 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. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. 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+2−2
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.33.0",+  "version": "0.34.0",   "description": "Interactive Gantt chart viewer for project timelines, with a table view 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. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. 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": {@@ -63,4 +63,4 @@     "name": "Drew Owens",     "email": "[email protected]"   }-}\ No newline at end of file+}