← Commit history

0.37.0: the first click lands, milestones show both ways and drag anywhere, rows delete from the chart, and the open panel follows the drag

Drew Owens ·e35df09602 · ·parent 49a07c9
4 files changed +493−51
docs/BUILD-NOTES.md+163−2
@@ -3,6 +3,162 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## 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+restarted.++**A. The swallowed first click, and what was actually causing it.**++0.36.0 recorded this as a known quirk and pointed at the `gesture`/`didDrag` drag state+machine. Instrumenting a copy of the client that exposes those variables settles it:+through the whole repro `didDrag` is `false`, `gesture` is `null` and no drag state exists+at any point. The drag machine was not involved.++What is involved is the SELECTION, which outlives the panel. Opening a task panel makes+that row the sole selection, and closing the panel with Cancel, Apply or Escape leaves the+selection exactly where it was. The bar click handler then ran `doSelect`, whose+unmodified branch TOGGLES: a row that is already the sole selection is dropped, `selected`+falls to zero, and the handler answers that by calling `closeEditor()`. So the click that+meant "open this task again" was read as "deselect", and the second click, starting from+an empty selection, worked. Scrolling was a red herring: it changes which bar sits under a+given point, so the suite's nth-visible-bar index happened to land back on the bar it had+just closed. With no scroll at all, opening a panel and clicking that same bar again+reproduces it every time, through all three ways of closing.++The fix keeps the toggle where it reads correctly and kills it where it does not. A plain+click on a bar now SELECTS that row outright unless the panel it would toggle is actually+on screen; while the panel IS up, clicking its bar still dismisses it, which is the+"the bar dismisses what the bar opened" behaviour and is asserted on its own. Modified+clicks are set-building and keep the toggle in every case. `selectOnly` was split so the+chart's click path can take the selection half without also moving the table's cursor,+which it has never done.++**And a second, genuinely stale flag, which is the one 0.36.0 was reaching for.**+`didDrag` was a sticky boolean set while a drag ran and cleared only by a click on the+BARS pane. A row reorder dragged in the label column set it and then produced its click on+the LABEL column, so nothing ever cleared it: the flag stayed armed and the next click on+a bar, any number of scrolls and minutes later, was eaten. Escape during a reorder set it+with no click following at all. This is exactly the hazard the table's own guard has+carried a comment about since 0.12.0 ("timestamped rather than a sticky flag ... a flag+left armed would then eat the next real one"), and the fix is to give the chart the same+answer: `didDrag` is gone, and the bars click guard reads the shared `dragEndedAt` clock.+The clock is stamped once, on mouseup, for every kind of drag, including the two that+previously stamped nothing (a reorder dropped on no target, and a reorder abandoned with+Escape, which is remembered by a `cancelledDrag` flag that the very next mouseup clears+unconditionally and so cannot go stale the way `didDrag` did).++Two things this had to be careful about. A resize handle and a milestone open a drag state+on mousedown with no movement threshold, so stamping the clock whenever a drag state+existed suppressed the click from a plain PRESS: the suite caught this immediately, as a+milestone that no longer opened its editor. `dragState.moved`, set on the first mousemove,+restores exactly the old semantics. And the document-level "a click outside closes the+editor" handler now reads the same clock, because a snapped bar cannot track the pointer+exactly and a drag of any length usually ends with the cursor beside the bar rather than on+it, which that handler was reading as "clicked away".++**B. A third milestone style: both.** The picker was a two-way toggle and is now a+three-way cycle, Lines, Diamonds, Both, persisted like the other two and accepted from a+plan's `view.milestones`. The CSS needed nothing added: each style hides the half it does+not draw, and `both` names neither, so it hides nothing. What did need changing were the+three places that tested `msStyle === 'line'` to decide whether to lay out the cap band,+which would have left the caps unplaced in the new style; they ask `msShowsLine()` now.++The phase recolour rule was already right and is now proven in this style: the dashed rule+carries the phase colour on `.ms-line`, and the cap takes it through `currentColor` on its+border and text, so one assignment moves both. The diamond is painted from `var(--text)`+and is never given a phase colour, which is deliberate: the phase is already being said by+the rule and the cap, and saying it a third time on the marker would lose the marker.+Collapse and filter needed nothing either, because they already act on `_lDiv`, `_bDiv`+(which holds the diamond) and `_msLine` together.++**C. Deleting a row from the chart.** The table has had a per-row delete button and a+Delete key since 0.19.0; the chart had neither, so a row could be created, renamed, moved+and rescheduled there and still only be removed by switching lens. Two ways in, both+landing on the existing `deleteRow`/`removeRow`, so there is one delete in this app however+it is reached: one undo entry, one changelog event, the uid retired once, the dependencies+on it dropped once. The multi-row path was lifted out of the table's key handler into+`deleteRows` and both lenses call it, so a set is still one undo entry and one batch.++The key is armed rather than immediate, which the table's is not, and for a reason: Delete+on the chart has no row under a cursor to aim it. It acts on the SELECTION, which may be+scrolled off screen and may be twenty rows. The first press says what would go and marks+it in both columns; the second press inside four seconds does it. Everything else withdraws+the question, through two capturing listeners so they run before whatever else the key or+the click was for: any other key, any mousedown, the timeout, opening a panel, and leaving+the lens. Escape stops there rather than falling through, because Escape's other job is to+drop the selection and the selection is the thing the user is still working with.++One gate had to be loosened to make the set case reachable at all. `appIsIdle()` refuses+while any panel is open, and picking a second row OPENS the batch panel by itself, so a+multi-selection could never have armed. The batch panel is not an editor someone opened on+a row, it is the selection made visible, so `chartDeleteReady()` allows it there and+refuses over a single-row panel, where Delete belongs to the field.++The buttons are a muted ghost that turns the table's red on hover, pushed to the far end of+the action row by `margin-left:auto` so the button reached for by habit is never the one+that removes the row. Measured at 190px of clear space from Apply.++**D. The diamond is a first-class target.** A plain click on a milestone used to CLEAR the+selection and open its editor, so the one row just aimed at was the one row that could not+be selected, and Alt+Up, Alt+Down and the table's cursor all read "nothing chosen". It+selects through `selectOnly` now, which hands the same row to the table, and the Alt+arrow+reorder needed no new code at all: it reads `soleSelectedRow()` and has always recoloured+`_msLine` on a lane change.++Dragging is the bigger change. A press on a milestone opened a date drag outright, so a+milestone was the one thing on the chart that could only ever move along the time axis. It+now arms the SAME pending gesture the bars use, keyed by uid instead of id, and one 5px+threshold decides: sideways moves the date, downwards moves the row. The vertical branch+was already uid-keyed from the drop onwards, so it took a one-line change to resolve the+row from a uid, and the phase recolour on drop was already there. A cap is horizontal only,+because it sits in the band at the top of the chart rather than on a row, and a milestone+label row is vertical only, because there is no time axis in the label column; the label+row is now armable at all, which it never was, since it carries no `data-id`.++The hit strip went from 16px to 22px round a 10px diamond. It lives inside the milestone's+own bar row and spans only that row's height, so it can never reach a bar above or below,+which the suite asserts directly by intersecting every hit strip with every bar.++**E. The open panel follows the drag.** The panel was built from a row and then forgot it,+so dragging the very bar whose panel was open left the fields showing the dates the row+used to have, and Apply would have written them back over the drag. `edSubject` records+which row the panel belongs to, and `syncEditorDates` writes start and end into the fields+whenever that row is repainted. Three call sites cover everything: the drag's fast repaint+(so the fields tick along at every snap step rather than only at the release), and+`updateBar`/`updateMilestone`, which are what undo, Apply and the keyboard moves all go+through, so a Ctrl+Z resyncs the panel for free. Only start and end are ever written, only+for the row the panel is editing, and only when the value would actually change, so a note+or an owner typed in there is never touched and a caret sitting in a field is left alone.+Apply after a drag is a no-op for the dates, which the suite checks against the sidecar+rather than against the fields.++**Verified headless on synthetic fixtures, both lenses, zero JS errors.** A new suite,+`v37`, covers all five in 203 assertions. The first click is asserted DIRECTLY, with the+retry 0.36.0 added removed: closing with Cancel, Apply and Escape, then clicking the same+bar; scrolling by wheel, by PageDown and by a scrollTop write before clicking (the app+hides the timeline scrollbar on purpose, so there is no thumb to drag and the third case is+what a thumb drag would produce); and the first click landing correctly on the bar, the+name text, the gap, the status pill and the owner pill. Drag suppression is proven still+intact for a bar move, a resize, a label-column reorder, a reorder abandoned with Escape,+the panel's own drag and a multi-select drag, in each case asserting that the release did+not additionally open a panel or a name editor. Both modes of the diamond work are run+twice, once in Diamonds and once in Both.++The standing battery is green on the fixed build: `regress` (26), `abc` (75, retry+removed), and `msphase`, whose output is byte-identical to the 0.36.0 recording. Of the+legacy suites, `cols`, `uid`, `memo` and `dblist` are unchanged, `deps` now completes where+it previously died on a harness error, and `mssel` reproduces its pre-existing harness+FATAL identically from the same fixture in the same order. 304 assertions in total across+`v37`, `regress` and `abc`, zero JS errors anywhere.++**A pre-existing overlap worth recording, not fixed here.** Milestone caps float in a band+over the top rows of the chart with `z-index: 7` and `pointer-events: auto`, so a cap can+sit on top of a task bar in the first row or two and take its clicks, including its resize+handle. This is not new and is not what this round was for; the suite works around it by+hit-testing every press point with `elementFromPoint` rather than assuming a rectangle is+reachable, which is a better habit for these suites anyway.+ ## Build notes, 2026-08-18 (0.36.0, the task panel is square and stays where you put it, the owner is a pill on the chart, and Enter finishes a rename)  Three changes, all in the client. `server.js` is untouched, so the running 0.35.0@@ -119,8 +275,13 @@ cell, lane headers and milestone caps) is green. 101 assertions in total, zero J **One pre-existing quirk found and deliberately not fixed.** Close a panel, scroll the chart, then click a bar: that first click is swallowed and the second one works. The same script reproduces it identically against a pristine 0.35.0 served on another port, so it is-not this build's doing; it lives in the `gesture`/`didDrag` state machine, which is the-drag subsystem this round had no mandate to touch. The suites retry once and say why.+not this build's doing. The suites retry once and say why.++> **Amended by 0.37.0.** This note guessed at the cause and guessed wrong: it named the+> `gesture`/`didDrag` state machine, and `didDrag` is `false` throughout the repro. The+> real cause was the selection outliving the panel, and there was a second, unrelated+> stale-flag bug in `didDrag` as well. Both are fixed in 0.37.0 and the retry is gone+> from the suite. See the 0.37.0 entry.  **Fixture note, still true.** Fixtures mirror the real steady state: zero-padded string uids and `project.nextUid`, so the server mints nothing at startup. And a suite must reset
gantt.html+327−46
@@ -276,7 +276,10 @@ body.col-resizing { cursor: col-resize; user-select: none; } .ms-diamond { position: absolute; top: 11px; width: 10px; height: 10px; transform: rotate(45deg) translateX(-50%); background: var(--text); border: 2px solid var(--bg); z-index: 5; pointer-events: none; transition: box-shadow 0.15s; } /* Transparent hover strip: carries the tooltip in both milestone styles, so the    full-height line never has to intercept pointer events over the whole chart. */-.ms-hit { position: absolute; top: 0; bottom: 0; width: 16px; transform: translateX(-50%); z-index: 6; cursor: pointer; }+/* A halo round a 10px diamond, so a small target is still an easy one. It lives INSIDE+   the milestone's own bar row and spans only that row's height, so however wide it+   gets it can never reach a bar on the row above or below. */+.ms-hit { position: absolute; top: 0; bottom: 0; width: 22px; transform: translateX(-50%); z-index: 6; cursor: pointer; } .ms-hit:hover ~ .ms-diamond, .ms-row:hover .ms-diamond { box-shadow: 0 0 10px rgba(230,237,243,0.3); } /* Line style: a dashed vertical rule spanning the whole chart at the milestone date. */ /* The dashed rule is drawn by the pseudo-element, not by the container, and the@@ -298,6 +301,11 @@ body.col-resizing { cursor: col-resize; user-select: none; }    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; }+/* Each style hides the half it does not draw. "both" names neither, so it hides+   nothing and the dashed rule, its cap and the diamond are all on screen together.+   The diamond keeps its own colour in every style: the phase is carried by the line+   and the cap outline (currentColor on .ms-line), and a phase-coloured diamond on top+   of a phase-coloured rule would say the same thing twice and lose the marker. */ :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@@ -306,6 +314,7 @@ body.col-resizing { cursor: col-resize; user-select: none; } :root[data-ms="line"] .ms-hit { display: none; } .label-row.ms::before { content: '\25C6\00a0'; } :root[data-ms="line"] .label-row.ms::before { content: '\00a6\00a0'; }+:root[data-ms="both"] .label-row.ms::before { content: '\00a6\25C6\00a0'; } .today-line { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--today); z-index: 5; pointer-events: none; } /* The line itself stays pointer-events:none. A full-height hit strip would sit    over every bar it crosses and eat their clicks, which is a worse trade than a@@ -369,6 +378,17 @@ body.ed-dragging { user-select: none; -webkit-user-select: none; } .de-btn.primary:hover { background: var(--accent-bright); } .de-btn.secondary { background: transparent; color: var(--text2); border: 1px solid var(--border); } .de-btn.secondary:hover { background: var(--row-hover); }+/* Destructive, and deliberately not next to Apply: margin-left:auto pushes it to the+   far end of the action row, so the button reached for by habit is never the one that+   takes a row out of the plan. Quiet until the pointer is on it, then the same red the+   table's row delete has used since 0.19.0. */+.de-btn.danger { background: transparent; color: var(--text3); border: 1px solid var(--border); margin-left: auto; }+.de-btn.danger:hover { color: #f85149; border-color: rgba(248,81,73,0.45); background: rgba(248,81,73,0.10); }+/* Armed for deletion: the rows the next Delete would take, marked in both columns and+   on a milestone's dashed rule, so the count in the prompt is never the only evidence+   of what is about to go. */+.label-row.del-armed, .bar-row.del-armed { background: rgba(248,81,73,0.13); box-shadow: inset 2px 0 0 #f85149; }+.ms-line.del-armed .ms-cap { border-color: #f85149; color: #f85149; } .dep-list { list-style: none; margin: 4px 0 0; } .dep-list li { display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: var(--text2); padding: 3px 0; } .dep-list .dep-rm { color: var(--text2); cursor: pointer; font-size: 13px; padding: 0 4px; border-radius: 4px; transition: color 0.15s; }@@ -970,7 +990,10 @@ function viewDefaults(){   const v = (project && project.view) || {};   const d = {};   if (v.grain && ['day','week','month','quarter','year'].includes(v.grain)) d.granularity = v.grain;-  if (v.milestones) d.msStyle = /^lines?$/.test(v.milestones) ? 'line' : 'diamond';+  // "both" is spelled out because it is the only one of the three that is neither a+  // line nor a diamond; anything else the author wrote still falls back to diamonds.+  if (v.milestones) d.msStyle = /^both$/i.test(v.milestones) ? 'both'+                             : /^lines?$/i.test(v.milestones) ? 'line' : 'diamond';   if (v.today !== undefined) d.showToday = !!v.today;   if (v.theme === 'light' || v.theme === 'dark') d.theme = v.theme;   if (v.labelWidth) d.labelW = Math.max(120, Math.min(760, +v.labelWidth || 0)) || undefined;@@ -995,6 +1018,16 @@ const prefs = Object.assign( function clampFontScalePref(){ if (FS_STEPS.indexOf(prefs.fs) < 0) prefs.fs = 1; }+// The three milestone styles, in the order the button cycles them. A hand-edited or+// stale localStorage value lands on the first one rather than on a mode with no CSS.+const MS_STYLES = ['line', 'diamond', 'both'];+function clampMsStylePref(){+if (MS_STYLES.indexOf(prefs.msStyle) < 0) prefs.msStyle = 'line';+}+// Which halves the current style is showing. Everything that measures or lays out the+// dashed rule and its cap asks this rather than testing for 'line', because 'both'+// shows them too and a test for one name would have left the caps unplaced there.+function msShowsLine(){ return prefs.msStyle !== 'diamond'; } function savePrefs(){ try { localStorage.setItem(PREF_KEY, JSON.stringify(prefs)); } catch {} } function applyTheme(){ document.documentElement.setAttribute('data-theme', prefs.theme); } function applyStartupPrefs(){@@ -1231,7 +1264,7 @@ $('hdr').innerHTML = `       <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="5" cy="6" r="2"/><circle cx="19" cy="18" r="2"/><path d="M7 6h6a3 3 0 0 1 3 3v7"/><path d="M14 14l2 3 2-3"/></svg><span class="btn-label">Deps</span></button>   </div>   <div class="actions">-    <button class="act-btn" id="btnMsStyle" title="Milestone style: diamond or dashed line">+    <button class="act-btn" id="btnMsStyle" title="Milestone style, cycled: dashed lines with tags, diamonds on their own row, or both at once">       <span id="msStyleIcon"></span><span id="msStyleLabel"></span></button>     <button class="act-btn" id="btnTheme" title="Toggle light and dark"><span id="themeLabel"></span></button>     <button class="act-btn" id="btnSections" title="Sections: add, rename, recolour, reorder or remove the plan's lanes"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h13"/></svg><span class="btn-label">Sections</span></button>@@ -1845,6 +1878,19 @@ function appIsIdle(){   return true; } function wireGlobalKeys(){+// An armed Delete is a question, and everything except the answer withdraws it. Both+// listeners capture, so they run before whatever else the key or the click is for and+// the armed state can never survive into an action the user took instead.+document.addEventListener('keydown',e=>{+  if(!delArm)return;+  if(e.key==='Delete')return;                 // the answer; handled in the main handler+  disarmDelete();+  // Escape's whole job here was to call off the delete, so it stops here. Letting it+  // fall through would also drop the selection, which is the thing the user is still+  // working with and did not ask to lose.+  if(e.key==='Escape'){e.preventDefault();e.stopPropagation();}+},true);+document.addEventListener('mousedown',()=>{ if(delArm)disarmDelete(); },true); document.addEventListener('keydown',e=>{   // Undo and redo live in the same handler and are therefore guarded identically,   // which is the point: whatever stops Ctrl+Z from firing stops Ctrl+Shift+Z too.@@ -1889,6 +1935,10 @@ document.addEventListener('keydown',e=>{   // on it. Table only, and only when a row is selected and no field is live, which   // is the same gate the arrows use, so Delete inside an editor only edits text.   if(e.key==='Delete'&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey){+    // The chart lens answers Delete too, on the second press. Checked before the+    // table gate because the two are mutually exclusive by view and this one has to+    // see the key first or tableNavReady would return and eat it.+    if(chartDeleteReady()){ chartDeleteKey(e); return; }     if(!tableNavReady())return;     const rows=visibleRowEls();     const i=rows.findIndex(t=>t.dataset.key===focusedKey);@@ -1899,17 +1949,7 @@ document.addEventListener('keydown',e=>{       const victims=selectedRows();       if(!victims.length)return;       e.preventDefault();-      const steps=[];-      victims.slice().reverse().forEach(v=>{-        const info=removeRow(v,true);-        if(info)steps.unshift({type:'delete',r:v,idx:info.idx,droppedDeps:info.droppedDeps});-      });-      pushUndo({type:'multi',steps});-      clearSelection();-      keepTailLast();applyFilter();refreshChartGeometry();renderTable();-      undoToast.innerHTML='Deleted '+steps.length+' rows &middot; <kbd>Ctrl+Z</kbd>';-      showToast(undoToast,4500,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;});-      scheduleSave('table-delete');+      deleteRows(victims,'table-delete');       focusRowAt(Math.min(i<0?0:i,visibleRowEls().length-1));       return;     }@@ -1990,7 +2030,7 @@ document.addEventListener('keydown',e=>{   if(reorderState&&reorderState.active){     reorderState.moveIds.forEach(mid=>{const r=rowByUid(mid);       if(r){r._lDiv.classList.remove('dragging');r._bDiv.classList.remove('dragging');}});-    hideDropIndicators();document.body.style.cursor='';reorderState=null;gesture=null;didDrag=true;+    hideDropIndicators();document.body.style.cursor='';reorderState=null;gesture=null;cancelledDrag=true;     return;   }   if(rowDrag||laneDrag){ endRowDrag(false); endLaneDrag(false); return; }@@ -2707,7 +2747,7 @@ function updatePinnedHeader(){ function maskTopBand(active){   const boundary=(active&&active._bDiv)?active._bDiv.getBoundingClientRect().bottom                  :timelineEl.getBoundingClientRect().top+46*(prefs.fs||1);-  if(prefs.msStyle==='line'){+  if(msShowsLine()){     items.forEach(r=>{ if(r.type!=='milestone'||!r._msLine)return;       const cap=r._msLine.querySelector('.ms-cap'); if(!cap)return;       cap.style.visibility=(cap.getBoundingClientRect().top>=boundary-1)?'':'hidden'; });@@ -2821,7 +2861,7 @@ function layoutMsCaps(){   // 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;+  if(!msShowsLine())return;   const caps=[];   items.filter(r=>r.type==='milestone'&&r._msLine&&!r._collapsed).forEach(r=>{     const cap=r._msLine.querySelector('.ms-cap');@@ -2897,7 +2937,7 @@ window.addEventListener('resize',scheduleRelayout); if(window.ResizeObserver)new ResizeObserver(scheduleRelayout).observe(timelineEl); // Web-font load changes tag widths and heights after the first measure, so re-run the // milestone cap collision layout once fonts settle, or tags can overlap on a cold load.-if(document.fonts&&document.fonts.ready)document.fonts.ready.then(()=>{if(prefs.msStyle==='line'&&prefs.view!=='table')layoutMsCaps();});+if(document.fonts&&document.fonts.ready)document.fonts.ready.then(()=>{if(msShowsLine()&&prefs.view!=='table')layoutMsCaps();}); }  @@ -2998,10 +3038,17 @@ function doSelect(uid, e){ // The table's focused row is set to the same row on the way through, so the two lenses // agree about what is current and the arrow keys pick up from the row you just renamed // rather than from wherever the focus happened to be left.-function selectOnly(r){+// This row IS the selection, full stop, with nothing else touched. selectOnly is this+// plus the table's focused row; the chart's click path has never moved the table's+// cursor and a bug fix is not the place to start, so it takes the half it needs.+function selectSole(r){   const u=selUid(r); if(!u)return;   selected.clear(); selected.add(u); selAnchor=u;   refreshSelVisuals(); updateSelCount();+}+function selectOnly(r){+  const u=selUid(r); if(!u)return;+  selectSole(r);   if(typeof setFocusedRow==='function'){     const k=rowKey(r);     if(k)setFocusedRow(k);@@ -3256,6 +3303,12 @@ let labelEditing=null;  // ── Unified pending gesture (vertical=reorder, horizontal=move) ── let reorderState=null, gesture=null;+// Escape cancels a reorder while the button is still down. The release that follows+// still produces a click, and that click is the tail of a drag the user has already+// abandoned, so it must not act. A flag rather than a timestamp because there is no+// telling how long the button stays down; the very next mouseup clears it either way,+// so unlike the sticky didDrag it cannot outlive the gesture that set it.+let cancelledDrag=false; const labelDropInd=document.createElement('div'); function mountLabelDropIndicator(){ labelDropInd.className='drop-indicator';labelsEl.appendChild(labelDropInd);@@ -3276,18 +3329,34 @@ function hideDropIndicators(){ function wireLabelDragArm(){ labelsEl.addEventListener('mousedown',e=>{   if(e.target.closest('.status-pill, .owner-pill'))return;   // editing a pill, not starting a drag-  const row=e.target.closest('.label-row[data-id]');+  const row=e.target.closest('.label-row[data-id], .label-row.ms');   if(!row)return;+  // A milestone label row carries no data-id, so it could never be armed and a+  // milestone was the one row in the plan that could not be dragged into another lane+  // from the name column. It arms by uid instead, the same key its diamond uses, and+  // resolves through the same machine.+  if(!row.dataset.id){+    const mr=items.find(x=>x._lDiv===row);+    if(!mr||!mr.uid)return;+    gesture={uid:mr.uid,startX:e.clientX,startY:e.clientY,src:'mslabel'};+    return;+  }   gesture={id:row.dataset.id,startX:e.clientX,startY:e.clientY,src:'label'}; }); }  // (bar body mousedown is handled below in the drag section) -let didDrag = false; function wireBarClicks(){ bars.addEventListener('click',e=>{-  if(didDrag){didDrag=false;return;}+  // A drag must not also count as a click on whatever it finished over. Timestamped+  // rather than a sticky flag, the same answer the table's guard has used since+  // 0.12.0 and for the same reason: a drag that ends anywhere but this pane never+  // produces a click HERE, so a boolean set on the way out is never cleared and then+  // eats the next real click, however much later it arrives. A row reorder dragged in+  // the label column did exactly that, and the bar you clicked minutes and any number+  // of scrolls later did nothing at all.+  if(Date.now()-dragEndedAt<150)return;   // While the mode is on, a click on the chart is a dependency click. No editor   // opens, nothing is selected, and a click on a milestone says why rather than   // quietly doing nothing.@@ -3310,20 +3379,44 @@ bars.addEventListener('click',e=>{       if(selected.size>1)openBatchEditor(); else closeEditor();       return;     }-    clearSelection(); openMilestoneEditor(msHit.dataset.msuid); e.stopPropagation(); return;+    // A plain click SELECTS the milestone and opens its editor, which is what a plain+    // click on a task bar has always done. It used to clear the selection instead, so+    // the one row you had just aimed at was the one row that could not be selected,+    // and Alt+Up, Alt+Down and the table's cursor all read "nothing chosen". selectOnly+    // hands the same row to the table, so the two lenses agree about what is current.+    if(mr)selectOnly(mr); else clearSelection();+    openMilestoneEditor(msHit.dataset.msuid); e.stopPropagation(); return;   }   const bar=e.target.closest('.bar[data-id]');   if(bar&&!e.target.classList.contains('drag-handle')){     const id=bar.dataset.id;     const br=items.find(d=>d.id===id);-    doSelect(br&&br.uid, e);+    // A plain click on a bar means "open this task", and it has to mean that on the+    // FIRST click. doSelect TOGGLES, which reads right while the panel is on screen+    // (the bar dismisses what the bar opened) and wrong the moment it is gone: the+    // selection OUTLIVES the panel, so the row whose panel was just closed is still+    // the sole selection, and the next click on its bar lands as "deselect", drops+    // the selection to zero and opens nothing. That is the swallowed first click.+    // Nothing to do with the drag machinery: it cost a second click every time+    // someone reopened the task they had just closed, and scrolling only made it+    // easier to hit, because a scrolled chart puts a different bar under the same+    // spot. A modified click is set-building and keeps the toggle in every case.+    const plainClick=!(e.ctrlKey||e.metaKey||e.shiftKey);+    const panelUp=editorEl.classList.contains('show')&&selected.size===1&&soleSelectedRow()===br;+    if(plainClick&&!panelUp)selectSole(br); else doSelect(br&&br.uid, e);     if(selected.size===1&&soleSelectedRow()===br)openSingleEditor(id);     else if(selected.size>1)openBatchEditor();     else closeEditor();     e.stopPropagation();   }else if(!e.target.closest('.editor')&&!e.target.closest('.dropdown')){clearSelection();closeEditor();} });-document.addEventListener('click',e=>{if(!e.target.closest('.editor')&&!e.target.closest('.bar[data-id]')&&!e.target.closest('.dropdown')&&!e.target.closest('.act-btn'))closeEditor();});+document.addEventListener('click',e=>{+  // The release that ends a drag is not a click OUTSIDE anything. A snapped bar cannot+  // track the pointer exactly, so a drag of any length usually finishes with the cursor+  // beside the bar rather than on it, and this handler was reading that as "clicked+  // away" and shutting the panel that had just spent the whole drag showing the dates.+  if(Date.now()-dragEndedAt<150)return;+  if(!e.target.closest('.editor')&&!e.target.closest('.bar[data-id]')&&!e.target.closest('.dropdown')&&!e.target.closest('.act-btn'))closeEditor();}); }  // ── Sections: the only places phases[] is mutated ──────────────────────────@@ -3666,6 +3759,7 @@ function wireSectionsButton(){ function openMilestoneEditor(uid){   const r=rowByUid(uid);if(!r||r.type!=='milestone')return;   closeEditor();+  edSubject=r;   editorEl.innerHTML=`<div class="drag-bar"></div>     <div class="de-row"><div style="flex:1"><label>milestone</label>       <input type="text" id="meName" value="${esc(r.name||'')}"></div></div>@@ -3673,7 +3767,8 @@ function openMilestoneEditor(uid){     <div class="de-note-row"><label>description <span class="de-hint">shown on hover</span></label>       <input type="text" id="meDesc" value="${esc(r.description||'')}"></div>     <div class="de-actions"><button class="de-btn primary" id="meApply">Apply</button>-      <button class="de-btn secondary" id="meClose">Cancel</button></div>`;+      <button class="de-btn secondary" id="meClose">Cancel</button>+      <button class="de-btn danger" id="meDelete" title="Delete this milestone from the plan. Undoable with Ctrl+Z">Delete</button></div>`;   const x=r._msHit?r._msHit.getBoundingClientRect():{left:window.innerWidth/2,bottom:200};   showEditor(x.left-150,x.bottom+8,false);   $('meApply').addEventListener('click',()=>{@@ -3688,13 +3783,15 @@ function openMilestoneEditor(uid){     updateMilestone(r);closeEditor();scheduleSave('milestone-editor');   });   $('meClose').addEventListener('click',closeEditor);+  $('meDelete').addEventListener('click',()=>deleteFromPanel(r)); }  // ── Single editor ── function openSingleEditor(id,anchor){   const r=items.find(d=>d.id===id);if(!r)return;+  edSubject=r;   const allTasks=items.filter(d=>d.id&&d.id!==id),deps=r.deps||[];-  editorEl.innerHTML=`<div class="drag-bar"></div><div class="de-row"><div><label>start</label><input type="date" id="deStart" value="${r.start}"></div><div><label>end</label><input type="date" id="deEnd" value="${r.end}"></div></div><div class="de-note-row"><label>note <span class="de-hint">shown on the bar</span></label><input type="text" id="deNote" maxlength="24" value="${esc(r.note||'')}" placeholder="e.g. 6m"></div><div class="de-dep-section"><label>dependencies</label><ul class="dep-list" id="deDepList"></ul><select id="deAddDep" style="margin-top:6px;"><option value="">+ add dependency...</option>${allTasks.filter(t=>!deps.includes(t.id)).map(t=>`<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('')}</select></div><div class="de-actions"><button class="de-btn primary" id="deApply">Apply</button><button class="de-btn secondary" id="deClose">Cancel</button></div>`;+  editorEl.innerHTML=`<div class="drag-bar"></div><div class="de-row"><div><label>start</label><input type="date" id="deStart" value="${r.start}"></div><div><label>end</label><input type="date" id="deEnd" value="${r.end}"></div></div><div class="de-note-row"><label>note <span class="de-hint">shown on the bar</span></label><input type="text" id="deNote" maxlength="24" value="${esc(r.note||'')}" placeholder="e.g. 6m"></div><div class="de-dep-section"><label>dependencies</label><ul class="dep-list" id="deDepList"></ul><select id="deAddDep" style="margin-top:6px;"><option value="">+ add dependency...</option>${allTasks.filter(t=>!deps.includes(t.id)).map(t=>`<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('')}</select></div><div class="de-actions"><button class="de-btn primary" id="deApply">Apply</button><button class="de-btn secondary" id="deClose">Cancel</button><button class="de-btn danger" id="deDelete" title="Delete this task from the plan. Undoable with Ctrl+Z">Delete</button></div>`;   renderDepList(id);   // Anchored to the bar in the chart. In the table the chart is display:none and   // every bar rect is zero, so the caller passes the cell it came from instead.@@ -3709,6 +3806,7 @@ function openSingleEditor(id,anchor){   $('deAddDep').addEventListener('change',function(){if(this.value){snapshot(id);if(!r.deps)r.deps=[];r.deps.push(this.value);renderDepList(id);drawDeps();scheduleSave('editor');const o=this.querySelector(`option[value="${r.deps[r.deps.length-1]}"]`);if(o)o.remove();this.value='';}});   $('deApply').addEventListener('click',()=>{snapshot(id);const ns=$('deStart').value,ne=$('deEnd').value;if(ns)r.start=ns;if(ne)r.end=ne;r.note=$('deNote').value;updateBar(id);closeEditor();scheduleSave('editor');});   $('deClose').addEventListener('click',closeEditor);+  $('deDelete').addEventListener('click',()=>deleteFromPanel(r)); }  function renderDepList(id){@@ -3788,6 +3886,7 @@ function updateMilestone(r){     const cap=r._msLine.querySelector('.ms-cap');     if(cap){cap.dataset.dates=fmt(r.date);cap.dataset.title=r.name;cap.dataset.body=r.description||'';}   }+  syncEditorDates(r);   drawDeps(); } function snapshotMs(r){@@ -3808,11 +3907,11 @@ function refreshMsChrome(r){ // restating those too. This used to patch only the geometry and the dates, so a // description restored from the sidecar (or edited in the table) left the bar // hovering the version that was in the data file.-function updateBar(id){const r=items.find(d=>d.id===id),be=barEls[id];if(!r||!be)return;if(r._noteEl)r._noteEl.textContent=r.note||'';const x1=toX(r.start),x2=toX(r.end);be.bar.style.left=x1+'px';be.bar.style.width=Math.max(x2-x1,6)+'px';be.bar.style.background=phaseColor(r.phase);be.bar.dataset.dates=fmt(r.start)+' → '+fmt(r.end);be.bar.dataset.title=r.name;be.bar.dataset.body=r.description||'';be.bar.dataset.prov=provLabel(r);drawDeps();}+function updateBar(id){const r=items.find(d=>d.id===id),be=barEls[id];if(!r||!be)return;if(r._noteEl)r._noteEl.textContent=r.note||'';const x1=toX(r.start),x2=toX(r.end);be.bar.style.left=x1+'px';be.bar.style.width=Math.max(x2-x1,6)+'px';be.bar.style.background=phaseColor(r.phase);be.bar.dataset.dates=fmt(r.start)+' → '+fmt(r.end);be.bar.dataset.title=r.name;be.bar.dataset.body=r.description||'';be.bar.dataset.prov=provLabel(r);syncEditorDates(r);drawDeps();} // The shape goes with the content: every opener declares whether it wants the square, // so closing has to leave the element without one rather than let the last panel's // aspect ride along into the next one.-function closeEditor(){editorEl.classList.remove('show','square');editorEl.innerHTML='';}+function closeEditor(){edSubject=null;editorEl.classList.remove('show','square');editorEl.innerHTML='';} function closeMenus(){exportMenu.classList.remove('show');importMenu.classList.remove('show');}  // ── Drag (edge-resize, body-move horizontal, reorder vertical) ──@@ -3839,18 +3938,25 @@ bars.addEventListener('mousedown',e=>{   gesture={id:bar.dataset.id,startX:e.clientX,startY:e.clientY,src:'bar',bar}; }); -// Milestones carry no id, so they sat outside the bar drag path entirely and could-// never be moved or edited. They get their own handler.+// Milestones carry no id, so they sat outside the bar drag path entirely. They now go+// through the SAME pending gesture the bars use, keyed by uid instead: sideways moves+// the date, downwards moves the row, and one 5px threshold decides which. Before this+// the press opened a date drag outright, so a milestone could only ever move along the+// time axis and the diamond was the one thing on the chart you could not drag into+// another lane. bars.addEventListener('mousedown',e=>{   if(depMode)return;   const hit=e.target.closest('.ms-hit, .ms-cap');   if(!hit)return;-  e.preventDefault();hideTip();   const r=rowByUid(hit.dataset.msuid);   if(!r||r.type!=='milestone')return;-  snapshotMs(r);-  dragState={kind:'ms',r,startX:e.clientX,origLeft:toX(r.date)};-  document.body.style.cursor='ew-resize';+  // preventDefault so a press that is still deciding cannot sweep a text selection+  // across the chart behind it.+  e.preventDefault();hideTip();+  // A cap is only ever a date drag. It sits in the band at the top of the chart, not+  // on a row, so "drag it down into the next lane" would be aiming at a place the+  // thing being dragged is not.+  gesture={uid:r.uid,startX:e.clientX,startY:e.clientY,src:hit.classList.contains('ms-cap')?'mscap':'ms'}; });  document.addEventListener('mousemove',e=>{@@ -3858,8 +3964,36 @@ document.addEventListener('mousemove',e=>{   if(gesture&&!dragState&&!reorderState){     const dx=e.clientX-gesture.startX, dy=e.clientY-gesture.startY;     if(Math.abs(dx)<5&&Math.abs(dy)<5)return;-    hideTip();didDrag=true;+    hideTip();     const id=gesture.id;+    // Bars are keyed by id, milestones by uid, and everything past this point wants+    // the ROW either way. A cap never resolves vertically, so its gesture is turned+    // into the horizontal one whichever way the pointer went.+    const grow=gesture.uid?rowByUid(gesture.uid):items.find(d=>d.id===id);+    if(gesture.src==='ms'||gesture.src==='mscap'||gesture.src==='mslabel'){+      if(!grow){gesture=null;return;}+      // The diamond decides by axis. A cap is horizontal only (it sits in the band, not+      // on a row) and a name in the label column is vertical only (there is no time+      // axis over there to drag a date along).+      const horizontal=gesture.src==='mscap'||(gesture.src==='ms'&&Math.abs(dx)>=Math.abs(dy));+      if(horizontal){+        snapshotMs(grow);+        dragState={kind:'ms',r:grow,startX:gesture.startX,origLeft:toX(grow.date)};+        if(grow.uid&&!selected.has(grow.uid)){selected.clear();selected.add(grow.uid);selAnchor=grow.uid;refreshSelVisuals();updateSelCount();}+        document.body.style.cursor='ew-resize';+      } else {+        reorderState={active:true,id:null};+        closeEditor();+        const moveIds=(grow.uid&&selected.has(grow.uid)&&selected.size>1)+          ? selectedRows().map(x=>x.uid) : [grow.uid];+        if(grow.uid&&!selected.has(grow.uid)){selected.clear();selected.add(grow.uid);selAnchor=grow.uid;refreshSelVisuals();updateSelCount();}+        reorderState.moveIds=moveIds;+        moveIds.forEach(mid=>{const r=rowByUid(mid);if(r){r._lDiv.classList.add('dragging');r._bDiv.classList.add('dragging');}});+        document.body.style.cursor='grabbing';+      }+      gesture=null;+      return;+    }      if(Math.abs(dx)>=Math.abs(dy)){       // Horizontal → move bar dates@@ -3900,7 +4034,12 @@ document.addEventListener('mousemove',e=>{    // Horizontal drag   if(dragState){-    didDrag=true;+    // A resize handle and a milestone both open a dragState on mousedown, with no+    // threshold: they have one obvious axis and waiting 5px to start would feel+    // sticky. So a PRESS alone is not a drag here, and only this flag says one really+    // happened. Without it the clock would be stamped by a plain click on a milestone+    // and the click it produced would be suppressed, which is the editor never opening.+    dragState.moved=true;     const dx=e.clientX-dragState.startX;     if(dragState.kind==='ms'){       // A milestone is a single date, so it lands on a grid line like any edge.@@ -3915,6 +4054,7 @@ document.addEventListener('mousemove',e=>{       const x1=toX(ri.start),x2=toX(ri.end);       be.bar.style.left=x1+'px';be.bar.style.width=Math.max(x2-x1,6)+'px';       be.bar.dataset.dates=fmt(ri.start)+' → '+fmt(ri.end);+      syncEditorDates(ri);     };     if(side==='move'){       // The start snaps; the DURATION is preserved, so the end lands at start plus@@ -3980,10 +4120,19 @@ document.addEventListener('mousemove',e=>{ });  document.addEventListener('mouseup',()=>{+  // Every kind of drag this handler winds up stamps the one clock, once, on the way+  // out, and every click guard reads that clock. Stamped HERE rather than inside each+  // branch below: a reorder dropped on nothing takes none of those branches, and a+  // reorder abandoned with Escape has already cleared itself, so both used to leave+  // the old sticky flag armed with nothing left to clear it. A gesture that never+  // passed the 5px threshold is a click and not a drag, and deliberately stamps+  // nothing: suppressing that one is how a plain click on a bar goes missing.+  if(dragState||reorderState||cancelledDrag)dragEndedAt=Date.now();+  cancelledDrag=false;   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='';dragEndedAt=Date.now();}+  if(dragState){scheduleSave(dragState.kind==='ms'?'drag-milestone':(dragState.side==='move'?'drag-move':'drag-resize'));dragState=null;document.body.style.cursor='';}    if(reorderState&&reorderState.active){     hideDropIndicators();@@ -4021,7 +4170,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;dragEndedAt=Date.now();+      drawDeps();scheduleSave('drag-reorder');     }     reorderState=null;   }@@ -4341,7 +4490,7 @@ $('btnHelp').addEventListener('click',e=>{     <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+Shift+Z</span>Redo, also Ctrl+Y. Any new edit clears it</div>     <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Up/Down</span>Move the selected row in the table. The pane only scrolls to keep it in view</div>     <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Enter</span>Edit the selected row's name. In a cell, commit and step down a row</div>-    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Delete</span>Delete the selected row. Undoable</div>+    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Delete</span>Delete the selected row. Undoable. On the chart it takes two presses: the first says what would go and marks it, the second does it</div>     <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+D</span>Add dependencies: click the task that comes first, then the one that waits on it</div>     <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+N</span>New task or milestone</div>     <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Alt+Up/Down</span>Move the row one slot. Under a sort it stays inside its tied group</div>@@ -4413,16 +4562,23 @@ $('btnTheme').addEventListener('click',()=>{ syncThemeBtn(); } +// One button, three styles, cycled in MS_STYLES order. The icon is the shape the+// style actually draws, so the button shows what you are about to get: the dashed+// rule, the diamond, or the two of them together.+const MS_BTN = { line:{icon:'¦',label:'Lines'}, diamond:{icon:'◆',label:'Diamonds'}, both:{icon:'¦◆',label:'Both'} }; function syncMsBtn(){-  const line = prefs.msStyle==='line';-  $('msStyleIcon').textContent = line ? '¦' : '◆';-  $('msStyleLabel').textContent = line ? 'Lines' : 'Diamonds';+  const m = MS_BTN[prefs.msStyle] || MS_BTN.line;+  $('msStyleIcon').textContent = m.icon;+  $('msStyleLabel').textContent = m.label; } function wireMsStyleAndGrain(){ $('btnMsStyle').addEventListener('click',()=>{-  prefs.msStyle = prefs.msStyle==='line' ? 'diamond' : 'line';+  prefs.msStyle = MS_STYLES[(MS_STYLES.indexOf(prefs.msStyle)+1) % MS_STYLES.length];   document.documentElement.setAttribute('data-ms',prefs.msStyle);-  savePrefs();syncMsBtn();+  // 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(); }); syncMsBtn(); @@ -4441,6 +4597,7 @@ $('grainSel').addEventListener('change',function(){ // the inline name editor and carries whatever was already typed. function openItemModal(r,carriedName){   closeEditor();+  edSubject=r;   const ms=r.type==='milestone';   const p=provOf(r)||{label:'',url:''};   editorEl.innerHTML=`<div class="drag-bar"></div>@@ -4460,7 +4617,8 @@ function openItemModal(r,carriedName){     <div class="de-note-row"><label>notes</label><input type="text" id="imDesc"></div>     <div class="de-note-row"><label>provenance</label><input type="text" id="imProv" placeholder="where this came from"></div>     <div class="de-actions"><button class="de-btn primary" id="imApply">Apply</button>-      <button class="de-btn secondary" id="imCancel">Cancel</button></div>+      <button class="de-btn secondary" id="imCancel">Cancel</button>+      <button class="de-btn danger" id="imDelete" title="Delete this row from the plan. Undoable with Ctrl+Z">Delete</button></div>     <div id="imErr" style="color:#f85149;font-size:11px;margin-top:6px;display:none"></div>`;   // values assigned, never interpolated, so nothing typed here can become markup   $('imName').value=carriedName!==undefined?carriedName:(r.name||'');@@ -4478,6 +4636,7 @@ function openItemModal(r,carriedName){     if(e.key==='Enter'&&e.target.tagName!=='BUTTON'){e.preventDefault();$('imApply').click();}     else if(e.key==='Escape'){e.preventDefault();closeEditor();}});   $('imCancel').addEventListener('click',closeEditor);+  $('imDelete').addEventListener('click',()=>deleteFromPanel(r));   $('imApply').addEventListener('click',()=>{     const name=$('imName').value.trim();     if(!name){const er=$('imErr');er.textContent='A name is required.';er.style.display='';$('imName').focus();return;}@@ -4929,7 +5088,29 @@ function edSquareSide(){ // placing on purpose: a display:none box measures zero, and the clamp is only honest // if it measures the real one. Both happen inside one task, so nothing paints between // them and there is no flash at the old position.+// ── The open panel is a live view of the row it is editing ──+//+// Which row the panel on screen belongs to. Nothing knew this before: the panel was+// built from a row and then forgot it, so dragging the very bar whose panel was open+// left the fields showing the dates the row USED to have, and Apply would have written+// them straight back over the drag.+let edSubject=null;+// The same numbers the bar is painting, in the fields, at every snap step rather than+// only at the release. Only the row the panel is actually editing, and only its dates:+// a note or an owner typed in there is the user's and is never touched. Each field is+// written only when it would change, so a caret sitting in one of them is left alone.+function syncEditorDates(r){+  if(!r||!edSubject||edSubject!==r)return;+  if(!editorEl.classList.contains('show'))return;+  const set=(id,v)=>{const el=$(id);if(el&&v&&el.value!==v)el.value=v;};+  if(r.type==='milestone'){ set('meDate',r.date); set('imEnd',r.date); return; }+  set('deStart',r.start); set('deEnd',r.end);+  set('imStart',r.start); set('imEnd',r.end);+} function showEditor(x,y,square){+  // Every panel in the app opens through here, so this is the one place an armed+  // Delete has to be withdrawn when the user's attention moves to a panel instead.+  disarmDelete();   editorEl.classList.toggle('square',!!square);   editorEl.classList.add('show');   placeEditor(x,y);@@ -6056,6 +6237,102 @@ function deleteRow(r){   showToast(undoToast,4500,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;});   scheduleSave('table-delete'); }+// A SET goes in one keystroke and comes back in one Ctrl+Z, the way a file manager+// behaves. Removed bottom-up so each recorded index is still valid when the next one+// goes. Both lenses come through here: the table's Delete key and the chart's armed+// Delete are the same delete, one undo entry and one changelog batch either way, and+// `via` is the only thing that differs so the log still says which lens it came from.+function deleteRows(victims,via){+  if(!victims||!victims.length)return 0;+  const steps=[];+  victims.slice().reverse().forEach(v=>{+    const info=removeRow(v,true);+    if(info)steps.unshift({type:'delete',r:v,idx:info.idx,droppedDeps:info.droppedDeps});+  });+  if(!steps.length)return 0;+  pushUndo({type:'multi',steps});+  clearSelection();+  keepTailLast();applyFilter();refreshChartGeometry();renderTable();+  undoToast.innerHTML='Deleted '+steps.length+' rows &middot; <kbd>Ctrl+Z</kbd>';+  showToast(undoToast,4500,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;});+  scheduleSave(via||'table-delete');+  return steps.length;+}+// ── Deleting from the chart lens ──+//+// The table has had a delete button on every row and a Delete key since 0.19.0. The+// chart had neither: a row could be created, moved, renamed and rescheduled there and+// still only be removed by switching lens. Two ways in, both landing on the functions+// above, so there is exactly one delete in this app however you reach it.+//+// The key is armed rather than immediate. Delete on the chart has no row under a+// cursor to aim it: it acts on the SELECTION, which may be off screen and may be+// twenty rows, so a single press is too cheap for what it does. The first press says+// what would go and marks it; the second press inside the window does it.+let delArm=null;+function disarmDelete(){+  if(!delArm)return;+  clearTimeout(delArm.timer);+  document.querySelectorAll('.del-armed').forEach(el=>el.classList.remove('del-armed'));+  delArm=null;+  if(undoToast.__armed){ undoToast.__armed=false; undoToast.classList.remove('show'); undoToast.innerHTML=UNDO_TOAST_DEFAULT; }+}+function armDelete(victims){+  disarmDelete();+  const n=victims.length;+  victims.forEach(v=>{+    if(v._lDiv)v._lDiv.classList.add('del-armed');+    if(v._bDiv)v._bDiv.classList.add('del-armed');+    if(v._msLine)v._msLine.classList.add('del-armed');+  });+  delArm={uids:victims.map(v=>v.uid),timer:setTimeout(disarmDelete,4000)};+  undoToast.__armed=true;+  undoToast.innerHTML='Press <kbd>Delete</kbd> again to delete '+n+(n===1?' row':' rows');+  showToast(undoToast,4000,()=>{undoToast.__armed=false;undoToast.innerHTML=UNDO_TOAST_DEFAULT;disarmDelete();});+}+// The chart's Delete is only live when the chart is what you are looking at and+// nothing else has the keyboard. appIsIdle() is the same gate the table's Delete and+// the arrow keys use, so a Delete inside a name field, a date input or an open panel+// still only edits text.+function chartDeleteReady(){+  if(prefs.view==='table')return false;+  if(!selected.size)return false;+  if(tblEditing||labelEditing||rowDrag||laneDrag||dragState||reorderState||gesture)return false;+  if(datePop&&datePop.classList.contains('show'))return false;+  if(isTypingTarget(document.activeElement))return false;+  // A panel open on ONE row is a row being edited, and Delete there belongs to the+  // field the user is in rather than to the plan. The batch panel is the exception: it+  // is not an editor someone opened on a row, it IS the multi-selection made visible,+  // and it opens by itself the moment a second row is picked. Refusing there would mean+  // a set could never be deleted from the chart at all, which is most of the point.+  if(editorEl.classList.contains('show')&&selected.size<2)return false;+  return true;+}+function chartDeleteKey(e){+  const victims=selectedRows();+  if(!victims.length){disarmDelete();return;}+  e.preventDefault();+  // The second press acts on what the first press NAMED. Anything that could have+  // changed the selection in between (a click, any other key, the timeout) has+  // already disarmed, so these two sets can only differ if something new arrived,+  // and re-arming on the fresh set is the honest answer to that.+  const same=delArm&&delArm.uids.length===victims.length&&victims.every(v=>delArm.uids.includes(v.uid));+  if(!same){armDelete(victims);return;}+  disarmDelete();+  deleteRows(victims,'chart-delete');+}+// One row, from a panel's Delete button. Straight to deleteRow, which is the table+// button's path: same undo entry, same toast, same retired uid, same dropped+// dependencies. The panel goes first so nothing is left open over a row that has left+// the plan, and the selection lets go of it so the next click on the chart is clean.+function deleteFromPanel(r){+  if(!r)return;+  disarmDelete();+  if(r.uid)selected.delete(r.uid);+  closeEditor();+  deleteRow(r);+  refreshSelVisuals();updateSelCount();+} function wireCopyIds(){ selCountEl.addEventListener('click',e=>{e.stopPropagation();copySelectedIds();}); const b=$('btnCopyIds');@@ -6086,6 +6363,9 @@ function applyView(){ function setView(v){   if(v!=='table'&&v!=='gantt')return;   if(prefs.view===v)return;+  // The marks live on chart rows. Leaving the lens leaves the question behind too,+  // rather than carrying an armed delete into a view that never showed it.+  disarmDelete();   if(v==='table'&&depMode)setDepMode(false);   // the mode is a chart mode   prefs.view=v;savePrefs();applyView();   if(v==='table'){renderTable();return;}@@ -6163,6 +6443,7 @@ async function init(){   assignRowKeys();   dropUndatedRows();   clampFontScalePref();+  clampMsStylePref();   applyStartupPrefs();   wireLaneToggle();   initTimeRange();
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.36.0",+  "version": "0.37.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.36.0",+  "version": "0.37.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+}