← Commit history

0.38.0: dependency arrows routed as a Z (horizontal exit, drop, horizontal entry), the caret lands where the name was clicked, and the milestone cap band stops anchoring to the window

Drew Owens ·9bd6e202c4 · ·parent dd05d25
4 files changed +240−13
docs/BUILD-NOTES.md+107
@@ -3,6 +3,113 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-18 (0.38.0, dependency arrows routed as a Z, the caret lands where the name was clicked, and the milestone cap band stops anchoring to the window)++Three changes, all in the client. `server.js` is untouched, so the running server was never+restarted.++**A. An arrow must never drop straight into its target.**++Reported from a screenshot: a vertical teal line falling out of one bar into the next, with+nothing in it to say which end was the cause. The route was one cubic,+`M x1,y1 C mx,y1 mx,y2 x2,y2`, with `mx` the midpoint of `x1` and `x2`. When a successor+starts the day its predecessor ends, `x1` and `x2` are the same pixel; `mx` lands on that+same pixel, both control points collapse onto it, and the "curve" degenerates into a+vertical line. Anything within a couple of days of that gave the same reading.++Every dependency is now routed as a Z with rounded elbows: a horizontal run out of the+source bar's END, a vertical drop, and a horizontal run into the target bar's START, where+the arrowhead already sat. Two cases, one function:++- Room between them (`x2 - x1 >= 2 * DEP_STUB`): out, down, in. The drop stays at the+  midpoint, clamped to leave a stub at each end, so a long span still reads as the wide+  sweep it always did and only the two ends are newly pinned horizontal.+- Too close, or the target starts BEFORE the source ends: out, down into a lane halfway+  between the two rows, back past the target's start, down again, in. The hook runs back+  at least one stub further than the exit, so the lane is wide enough to read even when+  the two bars begin and end on the very same day.++The corners come from a small `roundedPath` helper: quadratic elbows with the radius+clamped to half of each adjoining segment, so a tight elbow degrades to a square one rather+than overshooting into the segment past it. The first and last segments are left straight,+and that is what makes the tangents at the two ends horizontal rather than merely+approximately so. Stroke, colour, opacity and the arrowhead polygon are untouched, as are+the dependency semantics, the dep-mode interactions and the editor's hit areas: only the+`d` attribute changed.++Upward dependencies fall out of the same code, because nothing in it assumes `y2 > y1`.+Milestones are unchanged: they carry no `id`, so `barEls` has no entry for them and+`drawDeps` has always skipped them, which is what the "milestones cannot be dependencies+here" message says. What the milestone STYLES do change is row geometry, since the grid+rows appear and disappear with the style, so the suite re-measures every route in all three.++**B. The caret lands where the name was clicked.**++0.36.0 made the name text its own hit target, and clicking it opened the inline editor with+the whole name selected. Fixing one word therefore cost two clicks: one to open, one to put+the highlight down and the caret somewhere useful. `caretIndexAt` now maps the click x to a+caret index and the editor opens collapsed at that character.++The measurement is the browser's own hit test (`caretPositionFromPoint`, falling back to+`caretRangeFromPoint`, and to a proportional estimate if neither is there) run against the+name span, which holds the WHOLE name and lets CSS ellipsise it. That is what makes the+index correct rather than estimated, and it is font-independent, which matters because the+span and the input do not share a font. The ellipsis is the one case the hit test answers+badly: it is painted inside the box against the right edge, so a click there would land on+whichever character happens to sit under the clip. A hit within 14px of the right edge of a+clipped span is read as the end of the name instead.++Everything that could reach select-all before still can. Ctrl+A inside the field is+untouched (there is no document-level handler for it to fight). A double-click still+upgrades to the full item editor with the name selected. The Edit pencil, Shift+N and every+other programmatic opener pass no caret and so still arrive selected: the parameter is+optional and `null` means "select all", which is the old behaviour by default. Enter,+Escape and blur are exactly as 0.37.0 left them, and the once-per-load leak stays fixed.+The table's name cell gets the same treatment through the same helper, and only on the+click path, so a cell reached by Tab or Enter still opens selected.++**C. The milestone cap band was anchoring to the window, not to the plan.**++Reported as: in Diamonds, scroll somewhere, toggle to Both, and the cap band appears pinned+at the top of the current window and stays there. Instrumenting it found the cause one+level below the toggle. The band's anchor is the topmost expanded, non-empty section header+(0.34.0), and `capBandBase` measured it with `offsetTop`. That header is `position: sticky`,+and in Blink `offsetTop` on a sticky element reports where it is PINNED rather than where it+lives: scrolled 300px down, the first header's `offsetTop` reads 300 instead of 0, so the+band was laid out 300px into the content and stayed at that stale offset from then on.++So this was never really about the toggle. ANY caller that laid the band out while the plan+was scrolled poisoned it; the toggle was simply the one path a user takes that does so+without returning to the top first. The fix goes in the measurement rather than in the+caller: `capBandBase` suppresses the sticky for the one read and puts it back in the same+tick, before any paint, so the row gives up its real layout position and every caller lands+where a fresh render would. The today tag rides the same anchor and was stranded by the+same bug, so it is fixed by the same line.++The style toggle additionally now runs `updatePinnedHeader()`, the same pass the scroll+handler runs, rather than only the layout half of it. `maskTopBand` skips the caps entirely+while the style is Diamonds, so without this a cap that had scrolled under the pinned header+kept its stale `visibility:hidden` after the toggle brought it back into view. Phase-scoped+hiding was already correct and is unchanged: collapse is handled by `_collapsed` and+`.row-collapsed`, not by the band.++**Verification.** A new `v38` suite, 249 assertions, driving real input headless. The arrow+geometry is read off the RENDERED path with `getPointAtLength` rather than by parsing the+`d` attribute, so what is asserted is the curve the browser actually draws: the path is+located by its start point (an arrow that does not leave the source bar's end is not found+at all), then endpoints, end tangents, the direction of travel at each end, the whole+approach lying left of the target start, and the vertical travel being a real drop rather+than a diagonal. The degenerate same-x case additionally asserts the path has a horizontal+span of more than 28px, which is the assertion the old vertical line fails by definition.+A sweep re-checks every arrow on screen against the tangent rules, and all of it runs again+in Lines, Diamonds and Both. Caret placement is asserted as a collapsed selection at the+measured index, with the clickable positions derived from a live Range measurement rather+than an assumed character width. Full standing battery green alongside it: v37 203, regress+26, abc 75, v38 249, plus the legacy cols, uid, memo, dblist and deps suites, zero JS+errors throughout. `mssel` and `msphase` remain fixture-coupled and fail identically on+0.37.0 and 0.38.0 when served the same plan from the same port, so they are unchanged+rather than green.+ ## Build notes, 2026-08-18 (0.37.0, the first click lands, milestones can be shown both ways and dragged anywhere, rows can be deleted from the chart, and the open panel follows the drag)  Five changes, all in the client. `server.js` is untouched, so the running server was never
gantt.html+130−10
@@ -2850,9 +2850,24 @@ function capBandAnchor(){ // 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.+// The anchor header is position:sticky, and in Blink offsetTop on a sticky element+// reports where it is PINNED, not where it lives: scrolled 300px down, the top+// header's offsetTop reads 300 instead of 0. Every caller that laid the band out+// while the plan was scrolled therefore anchored it to the viewport, and the caps+// stayed at that stale offset from then on. Toggling the milestone style was the way+// to see it, because that path lays the band out and nothing about it returns the+// plan to the top first. Suppress the sticky for the one measurement and the row+// gives up its real layout position; it is put back in the same tick, before any+// paint, so nothing flickers. function capBandBase(){   const g=capBandAnchor();-  if(g&&g._bDiv)return Math.round(g._bDiv.offsetTop+g._bDiv.offsetHeight)+4;+  if(g&&g._bDiv){+    const el=g._bDiv, was=el.style.position;+    el.style.position='static';+    const top=el.offsetTop+el.offsetHeight;+    el.style.position=was;+    return Math.round(top)+4;+  }   return Math.round(28*(prefs.fs||1))+4; } function layoutMsCaps(){@@ -2959,6 +2974,54 @@ function applyFilter(){applyCollapse();items.forEach(r=>{let vis=true;if(r.phase  // ── Dependencies ── function sb0(entry){ return entry.row || entry.bar.closest('.bar-row'); }+// An arrow leaves a bar the way the work does: out of its END, sideways, and it+// arrives at the next one's START, sideways again. It never drops straight into the+// target. Two bars that end and start on nearly the same day used to put x1 and x2+// within a pixel of each other; both control points of the single cubic landed on+// that same x and the curve collapsed into a vertical line falling out of one bar+// into the other, with nothing in it to say which end was the cause and which the+// effect.+//+// So the route is always a Z with rounded elbows: a horizontal run out of the source,+// a vertical drop, and a horizontal run into the target's left edge, where the+// arrowhead sits. DEP_STUB is the shortest either horizontal run may be, which is+// what keeps the two ends readable as horizontal however close the bars are.+const DEP_STUB=16, DEP_R=10;+// A polyline with its corners rounded off. Quadratic corners, each radius clamped to+// half of the two segments it joins, so a tight elbow degrades to a square one+// instead of overshooting into the segment past it. The first and last segments are+// left straight, and that is what makes the tangents at the two ends horizontal.+function roundedPath(pts,rad){+  let d='M'+pts[0][0]+','+pts[0][1];+  for(let i=1;i<pts.length-1;i++){+    const px=pts[i-1][0],py=pts[i-1][1],cx=pts[i][0],cy=pts[i][1],nx=pts[i+1][0],ny=pts[i+1][1];+    const d1=Math.hypot(cx-px,cy-py)||1, d2=Math.hypot(nx-cx,ny-cy)||1;+    const r=Math.min(rad,d1/2,d2/2);+    d+=' L'+(cx+(px-cx)/d1*r)+','+(cy+(py-cy)/d1*r)+      +' Q'+cx+','+cy+' '+(cx+(nx-cx)/d2*r)+','+(cy+(ny-cy)/d2*r);+  }+  return d+' L'+pts[pts.length-1][0]+','+pts[pts.length-1][1];+}+// The route from the end of one bar to the start of the next, in either direction.+//   room between them : out, down, in. The drop stays near the middle, so a long+//                       span still reads as the wide sweep it always did.+//   too close, or the target starts BEFORE the source ends : out, down into a lane+//                       between the two rows, back past the target's start, down+//                       again, in. The hook is what replaces the old vertical line.+// Either way the first segment leaves the source horizontally and the last enters the+// target horizontally from x < x2, so the arrowhead always points right into the+// bar's start edge.+function depPath(x1,y1,x2,y2){+  const xa=x1+DEP_STUB, xb=x2-DEP_STUB;+  if(xb>=xa){+    const xd=Math.min(Math.max(x1+(x2-x1)*0.5,xa),xb);+    return roundedPath([[x1,y1],[xd,y1],[xd,y2],[x2,y2]],DEP_R);+  }+  // Run back at least one more stub past the exit, so the lane is wide enough to+  // read even when the two bars begin and end on the very same day.+  const back=Math.min(xb,xa-DEP_STUB), ym=(y1+y2)/2;+  return roundedPath([[x1,y1],[xa,y1],[xa,ym],[back,ym],[back,y2],[x2,y2]],DEP_R);+} function drawDeps(){   depSvg.innerHTML='';let maxY=0;   items.forEach(r=>{if(!r.deps||!r.id)return;const tgt=barEls[r.id];if(!tgt)return;@@ -2972,8 +3035,8 @@ 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,mx=x1+(x2-x1)*0.5;-      const p=document.createElementNS('http://www.w3.org/2000/svg','path');p.setAttribute('d',`M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`);depSvg.appendChild(p);+      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 p=document.createElementNS('http://www.w3.org/2000/svg','path');p.setAttribute('d',depPath(x1,y1,x2,y2));depSvg.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}`);depSvg.appendChild(a);       maxY=Math.max(maxY,y1,y2);});});   depSvg.setAttribute('height',Math.max(maxY+40,bars.scrollHeight));@@ -3189,9 +3252,10 @@ labelsEl.addEventListener('click',e=>{   // 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')){+  const ntEl=e.target.closest('.name-text');+  if(ntEl){     if(selected.size)clearSelection();-    editLabelInline(r);+    editLabelInline(r,caretIndexAt(ntEl,e.clientX,r.name||''));     return;   }   // The gap: select the row. selUid is null for anything that cannot be selected, so@@ -3233,7 +3297,42 @@ function closeLabelEditors(save){   labelEditing=null;   return true; }-function editLabelInline(r){+// Where in a name the pointer landed, as a caret index into the string. Opening the+// editor by clicking the text used to select the WHOLE name, so the first thing a user+// wanting to fix one word had to do was click a second time to put the highlight down+// and the caret somewhere useful. Now the caret starts where the click was. Select-all+// is still one Ctrl+A away, and a double-click still arrives at the full editor with+// the name selected, so nothing that could reach it before has lost it.+//+// The span holds the whole name and lets CSS ellipsise it, so hit-testing the span+// reads the real character under the cursor. Past the clip there is nothing to hit:+// a click on the ellipsis, or anywhere right of the text, means the end of the name.+function caretIndexAt(el,clientX,text){+  if(!el)return null;+  const box=el.getBoundingClientRect();+  const y=box.top+box.height/2;+  const clipped=el.scrollWidth-el.clientWidth>1;+  // The ellipsis is painted INSIDE the box against its right edge, so a hit there+  // would land on whichever character happens to sit under the clip rather than at+  // the end of a name the user cannot even see the rest of.+  if(clientX>=box.right-(clipped?14:2))return text.length;+  if(clientX<=box.left)return 0;+  let hit=null;+  try{+    if(document.caretPositionFromPoint){+      const c=document.caretPositionFromPoint(clientX,y);+      if(c)hit={n:c.offsetNode,o:c.offset};+    } else if(document.caretRangeFromPoint){+      const c=document.caretRangeFromPoint(clientX,y);+      if(c)hit={n:c.startContainer,o:c.startOffset};+    }+  }catch(_){}+  if(hit&&hit.n&&hit.n.nodeType===3&&el.contains(hit.n))return Math.max(0,Math.min(text.length,hit.o));+  // No hit test to be had: fall back to the fraction of the way across the text.+  const w=el.scrollWidth||box.width;+  return w>0?Math.max(0,Math.min(text.length,Math.round((clientX-box.left)/w*text.length))):0;+}+function editLabelInline(r,caret){   const lDiv=r._lDiv;   if(!lDiv||lDiv.querySelector('.label-edit'))return;   closeLabelEditors(true);        // commit whatever else was open, never orphan it@@ -3296,7 +3395,12 @@ function editLabelInline(r){   inp.addEventListener('click',e=>e.stopPropagation());   inp.addEventListener('dblclick',e=>e.stopPropagation());   lDiv.appendChild(inp);-  inp.focus();inp.select();+  inp.focus();+  // A caret index means the field was opened by clicking the text, at that character.+  // Everything else (the Edit button, Shift+N, a header's phase name) still arrives+  // with the whole value selected, ready to be typed over.+  if(caret==null)inp.select();+  else{const n=Math.max(0,Math.min(inp.value.length,caret));inp.setSelectionRange(n,n);}   labelEditing=inp; } let labelEditing=null;@@ -4578,7 +4682,14 @@ $('btnMsStyle').addEventListener('click',()=>{   // Both mode brings the grid row back and the caps with it, so the band has to be   // laid out again on the way in: the caps were left unplaced while the style was   // diamonds, and nothing else would measure them until the next relayout.-  savePrefs();syncMsBtn();layoutMsCaps();sizeTailroom();drawDeps();+  //+  // The style also adds or removes the milestone rows, which moves every row under+  // them, so this runs the same pass the scroll handler does rather than only the+  // layout half of it: without it the caps keep the visibility they were left with+  // while the style was diamonds, where maskTopBand skips them entirely, and a cap+  // that scrolled under the pinned header stays hidden after the toggle brings it+  // back into view.+  savePrefs();syncMsBtn();layoutMsCaps();sizeTailroom();drawDeps();updatePinnedHeader(); }); syncMsBtn(); @@ -5994,7 +6105,7 @@ function openCellList(sel,td){   window.addEventListener('resize',closeCellList);   syncCellList(); }-function startCellEdit(td){+function startCellEdit(td,caret){   if(tblEditing)return;   const tr=td.closest('tr'); if(!tr)return;   setFocusedRow(tr.dataset.key);@@ -6073,6 +6184,11 @@ function startCellEdit(td){     // let typing overwrite. Notes are prose you almost always add to, so the     // caret goes to the end instead of destroying what is there on first keypress.     if(field==='notes'){ const n=el.value.length; el.setSelectionRange(n,n); }+    // A name cell clicked on its text is the table's half of the same gesture the+    // chart's labels answer: the caret starts at the character that was clicked, so+    // fixing one word costs one click. Reached by Tab or Enter it still selects all,+    // because there was no click and so no character to start at.+    else if(caret!=null){ const n=Math.max(0,Math.min(el.value.length,caret)); el.setSelectionRange(n,n); }     else el.select();   }   // Date cells get focus so the keyboard works at once, but the native calendar@@ -6219,7 +6335,11 @@ tableBody.addEventListener('click',e=>{     return;   }   if(selected.size)clearSelection();     // a plain click collapses the set, as on the chart-  const td=e.target.closest('td.editable');if(td)startCellEdit(td);+  const td=e.target.closest('td.editable');+  if(td){+    const tn=td.dataset.field==='name'?(e.target.closest('.trunc')||td.querySelector('.trunc')):null;+    startCellEdit(td,tn?caretIndexAt(tn,e.clientX,tn.textContent||''):undefined);+  } }); } // One click, undoable. A confirm dialog for something Ctrl+Z already covers is
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.37.0",+  "version": "0.38.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.37.0",+  "version": "0.38.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+}