← Commit history

0.22.4: the stuck teal outlines were not a selection

Drew Owens ·cb8bd35f3a · ·parent 8223763
Reported as two rows stuck with the teal selection outline in the chart's label column, clearable by neither Enter nor Escape. They were not selected. .multi-sel, the multi-select mark, is a faint teal BACKGROUND; the accent BORDER in that column belongs to .label-edit, the inline name editor. There were two name editors open at once and neither could be closed. Three defects lined up. The editor's only guard was per-row, so a second row could mount its own while the first was still on screen, and labelEditing tracked only the last, so the first was forgotten entirely. Enter and Escape are handled ON the input, so an editor that has lost focus cannot dismiss itself, and the document's Escape stack had no rung for label editing at all: the orphan was not listening and nothing else was listening for it. And the label column's click handler had no drag guard, so a vertical reorder drag, which ends with a click on whatever row the pointer landed on, opened a name editor on a row nobody aimed at. Now: one editor at a time enforced by closing any other before opening, with closeLabelEditors() working off a live query rather than a variable so it cannot lose one; a rung in the Escape stack where a chart-side innermost editor belongs; and the same dragEndedAt timestamp the table got in 0.15.0, for the reason 0.12.0 recorded, that a sticky boolean goes stale when a drag ends elsewhere. Two more fell out. A table row focus left from an earlier visit was swallowing the Escape meant for the chart's selection, because that rung did not check which lens was on screen. And the batch editor had been unreachable from a ctrl-click: the label column is not in the document-level click-outside-closes-the-editor allowlist, so that handler saw the very click that opened it. Same shape as the Deps cell in 0.22.0. Selection model confirmed from the code: only ctrl and shift build the set, nothing without a modifier ever adds to it. A plain label click is a name edit and now also collapses any multi-select; empty space in the column clears it. refreshSelVisuals was already correct, clearing every mark globally before re-applying, which is what ruled the selection out as the cause early. Keyboard sweep 55 of 55, column battery 35 of 35, zero JS errors.
4 files changed +104−7
docs/BUILD-NOTES.md+56
@@ -3,6 +3,62 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-11 (0.22.4, the stuck teal outlines were not a selection)++Reported as two rows stuck with the teal selection outline in the chart's label+column, clearable by neither Enter nor Escape. They were not selected. `.multi-sel`,+the multi-select mark, is a faint teal BACKGROUND; the accent BORDER in that column+belongs to `.label-edit`, the inline name editor. There were two name editors open+at once, and neither could be closed.++**Three separate defects lined up to produce it.**++The editor's only guard was per-row (`if(lDiv.querySelector('.label-edit'))return`),+so a second row could mount its own while the first was still on screen, and+`labelEditing` tracked only the last one, so the first was forgotten by the app+entirely. There is one editor at a time now, enforced by closing any other before+opening, and `closeLabelEditors()` works off a live query rather than a variable, so+it cannot lose track of one.++Enter and Escape are handled ON the input, so an editor that has lost focus can+never dismiss itself, and the document's Escape stack had no rung for label editing+at all. That is why neither key did anything: the orphan was not listening and+nothing else was listening for it. It has a rung now, sitting where a chart-side+innermost editor belongs, after the dependency-mode rungs and before the table's.++The label column's click handler had no drag guard. A vertical reorder drag ends+with a click on whatever row the pointer landed on, and that click opened a name+editor on a row the user never aimed at. The bars have had `didDrag` for this since+0.12.0 and the table got a timestamp in 0.15.0; the label column had neither. It+uses the same `dragEndedAt` timestamp now, for the reason 0.12.0 recorded: a drag+that ends somewhere else never clears a sticky boolean, and the stale flag then eats+the next real click.++**Two more things fell out of looking at it.** A table row focus left over from an+earlier visit was swallowing the Escape meant for the chart's selection, because+that rung did not check which lens was on screen; it does now. And the batch editor+had been unreachable from a ctrl-click for some time: the label column is not in the+document-level "a click outside closes the editor" allowlist, so that handler saw+the very click that opened it and shut it again. Same shape as the Deps cell in+0.22.0, and the rule is worth stating plainly: any surface that OPENS the editor has+to answer that handler.++**The selection model, confirmed from the code rather than assumed.** Only ctrl and+shift build the set; nothing without a modifier ever adds to it. A plain click on a+label is a name edit (0.14.0) and now also collapses any multi-select, the way+clicking one item in a list drops the rest, and a click on empty space in the label+column drops everything. `refreshSelVisuals` was already correct: it clears every+mark globally before re-applying, so it could never orphan an outline, which is what+ruled the selection out as the cause early.++Verified: two clicks leave one editor and it is on the second row; Escape from the+document closes it; an editor deliberately blurred first is still dismissable, which+is the reported bug; a reorder drag ending over a row opens nothing; ctrl-click+builds a set of two with the count chip and the batch editor open; Escape clears it+in a bounded two presses, editor first then selection, with a stale table row focus+present; a plain click collapses the set; empty space clears it. Keyboard sweep+55 of 55 and the column battery 35 of 35, zero JS errors.+ ## Build notes, 2026-08-11 (0.22.3, four small fixes with one interesting cause)  **The milestone tags and the dashed rules were the same one declaration.** A tag like
gantt.html+45−4
@@ -1588,11 +1588,17 @@ document.addEventListener('keydown',e=>{   // is making it.   if(depMode&&depPending){ setDepPending(null); return; }   if(depMode){ setDepMode(false); return; }+  // The chart's own inline name editor. It was missing from this stack entirely, so+  // an editor that had lost focus could not be dismissed by any key at all.+  if(closeLabelEditors(false)) return;   if(tblEditing){ tblEditing.cancel(); return; }   if(editorEl.classList.contains('show')){ closeEditor(); return; }   // The row focus is the outermost thing the table owns, so it goes after the   // field and after any panel, and before the chart's own selection.-  if(focusedKey){ setFocusedRow(null); return; }+  // Only while the table is the lens looking at it. A row focus left over from an+  // earlier table visit is invisible in the chart, and it used to swallow the Escape+  // that was meant for the chart's selection.+  if(focusedKey&&prefs.view==='table'){ setFocusedRow(null); return; }   clearSelection(); closeMenus(); }); }@@ -2304,17 +2310,36 @@ function updateSelCount(){if(selected.size>1){selCountEl.textContent=`${selected function wireLabelClicks(){ labelsEl.addEventListener('click',e=>{   if(reorderState||laneDrag)return;+  // A drag that ends over a row produces a click on it, and this used to answer that+  // click by opening the name editor on whatever the pointer happened to land on.+  // Timestamped rather than a sticky flag, for the reason 0.12.0 recorded: a drag+  // that ends somewhere else never clears a boolean, and the stale flag then eats+  // the next real click.+  if(Date.now()-dragEndedAt<150)return;   if(e.target.closest('.lane-toggle')||e.target.closest('.row-edit'))return;   const row=e.target.closest('.label-row[data-id], .label-row.ms');-  if(!row)return;+  // Empty space in the label column is a click on nothing, which is how every list+  // in the world drops a selection.+  if(!row){ if(!e.ctrlKey&&!e.metaKey&&!e.shiftKey){ closeLabelEditors(true); clearSelection(); } return; }   const r=items.find(x=>x._lDiv===row);   if(!r)return;   if(e.ctrlKey||e.metaKey||e.shiftKey){     if(!r.id)return;+    // The document-level "a click outside closes the editor" handler does not have+    // the label column in its allowlist, so it was seeing this very click and+    // shutting the batch editor the line below had just opened. Same shape as the+    // Deps cell in 0.22.0: any surface that OPENS the editor has to answer that+    // handler.+    e.stopPropagation();+    closeLabelEditors(true);     doSelect(r.id,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); }); // The second click of a double-click upgrades to the full editor, carrying@@ -2333,9 +2358,24 @@ labelsEl.addEventListener('dblclick',e=>{ }); } +// Exactly one of these may be open at a time. There used to be no rule at all: the+// only guard was per-row, so a second row could mount its own editor while the first+// was still on screen, and `labelEditing` tracked only the last one, so the first was+// forgotten entirely. An editor that has lost focus can never dismiss itself either,+// because Enter and Escape are handled on the input, so an orphan sat there with its+// accent border looking exactly like a stuck selection.+function closeLabelEditors(save){+  const live=[...document.querySelectorAll('.label-edit')];+  if(!live.length)return false;+  live.forEach(inp=>{ if(inp.__finish)inp.__finish(!!save); else if(inp.__cancel)inp.__cancel(); });+  document.querySelectorAll('.label-edit').forEach(el=>el.remove());   // belt and braces+  labelEditing=null;+  return true;+} function editLabelInline(r){   const lDiv=r._lDiv;   if(!lDiv||lDiv.querySelector('.label-edit'))return;+  closeLabelEditors(true);        // commit whatever else was open, never orphan it   const inp=document.createElement('input');   inp.type='text';inp.className='label-edit';inp.value=r.name||'';   let done=false;@@ -2357,6 +2397,7 @@ function editLabelInline(r){     } else restore();   };   inp.__cancel=()=>{done=true;restore();};+  inp.__finish=finish;   inp.addEventListener('keydown',e=>{e.stopPropagation();     if(e.key==='Enter'){e.preventDefault();finish(true);}     else if(e.key==='Escape'){e.preventDefault();finish(false);}});@@ -2731,7 +2772,7 @@ document.addEventListener('mouseup',()=>{   gesture=null;   if(!reorderState&&!laneDrag)hideDropIndicators(); -  if(dragState){scheduleSave(dragState.kind==='ms'?'drag-milestone':(dragState.side==='move'?'drag-move':'drag-resize'));dragState=null;document.body.style.cursor='';}+  if(dragState){scheduleSave(dragState.kind==='ms'?'drag-milestone':(dragState.side==='move'?'drag-move':'drag-resize'));dragState=null;document.body.style.cursor='';dragEndedAt=Date.now();}    if(reorderState&&reorderState.active){     hideDropIndicators();@@ -2769,7 +2810,7 @@ document.addEventListener('mouseup',()=>{         moveHint=moveItems.length===1?'"'+(moveItems[0].name||'a row')+'"':moveItems.length+' rows';       }       applyCollapse();reflowRows();applyFilter();refreshChartGeometry();renderTable();-      drawDeps();scheduleSave('drag-reorder');didDrag=true;+      drawDeps();scheduleSave('drag-reorder');didDrag=true;dragEndedAt=Date.now();     }     reorderState=null;   }
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.22.3",+  "version": "0.22.4",   "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.22.3",+  "version": "0.22.4",   "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+}