← Commit history

0.14.0: rename in the label column, a full item editor, cross-lane task drops, and collapsible lanes

Drew Owens ·eb0e7c58e8 · ·parent 19cf117
A single click on a chart label edits the name right there, mounted in the same tick and focused with the text selected. Double-clicking upgrades to a full editor covering every field, carrying whatever was already typed across, so the fast path never pays for the slow one. Deferring the single click to wait out a possible double would have made plain renaming feel sluggish, which was the one thing ruled out. Modifier clicks still select and never start an edit. Dropping a task across a lane boundary changes its lane: on the chart the group header above a row IS its lane, so a row under a different header has moved, and the bar recolours. The phase restore folds into the order snapshot already being pushed, so the gesture stays one Ctrl+Z. Dragging a lane collapses every lane for the duration, because choosing a position among five headers is easy and choosing one among seventy rows means aiming off-screen. The prior collapse state is captured and restored exactly on drop or cancel, and never written to prefs. Collapse is view state, in prefs beside theme and grain, with the chevron and row count at the right of each header and a tooltip saying it is yours alone. Collapsing hides rows, not time: the axis does not move and Fit still fits the whole span. Everything that measures rows learned about it: arrows skip a collapsed endpoint, the cap collision layout ignores hidden caps and their dashed rules go with them, and the tailroom measures the last visible row. Two temporal-dead-zone bugs caught on the way, the same shape as 0.11.0's: the collapse block ran before its own const existed, which threw right after the row loop and looked like missing bars rather than an error. It sits below prefs now. That is twice this file's long module-level prologue has bitten.
6 files changed +332−17
README.md+4
@@ -10,6 +10,10 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau  **Four statuses.** Not started, in progress, blocked, complete. Plans written against the older "live" and "planned" still read correctly without being changed. +**Collapse a lane.** The chevron at the right of each phase header hides that lane's rows and shows how many. Collapsing hides rows, not time: the axis does not move and Fit still fits the whole plan. This is your view only, it is not part of the plan.++**Rename in place.** Click a name in the chart's label column to edit it right there; double-click for the full editor with every field. Drag a task across a lane boundary and it changes lane, one undo for the whole move.+ **Drag to reorder.** Click and hold a row for a moment to lift it, then drop it where it belongs. Dropping into another lane moves it there, position and phase in one step and one undo. Drag a lane header on the chart to move the whole lane. Dates read `17-Aug-2026` throughout; stored data stays ISO.  **Edit the plan from the table.** Names, phase, owner, dates, status, notes and provenance are all editable in place, and a row can be deleted with one click and an undo toast. Moving a row to another phase re-homes it under that lane's group header on the chart, not just in the data. Deletions are recorded explicitly in the sidecar, so restoring a saved plan can never confuse "removed on purpose" with "row lost".
SKILL.md+4
@@ -228,6 +228,10 @@ named in its own column. | Notes | `description`, truncated | same | | Provenance | `provenance` as a chip, linked when the url is http(s) | same | +**Collapse.** Each phase header carries a chevron and a row count. Collapsed lanes are view state in `localStorage` (`prefs.collapsed`), not plan state, so they are per-person and never written to the data file or the sidecar. Collapsing hides rows only: the date axis and Fit are unaffected.++**Label editing.** A single click on a chart label edits the name inline; a double-click opens the full item editor. Dragging a task across a group header changes its phase.+ **Reordering.** Click and hold a row (about 350ms) to lift it, drag, drop. Only available in plan order, since a sorted view is a lens and not the plan. Dropping into another lane sets phase and position as one undo. On the chart, dragging a group header moves that whole lane.  **Dates** display as `17-Aug-2026`. Stored values (data file, sidecar, changelog payloads, CSV) stay ISO `yyyy-mm-dd`.
docs/BUILD-NOTES.md+47
@@ -3,6 +3,53 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-07 (0.14.0)++**The label column edits the name; the bar still owns the schedule.** A single+click on a task or milestone label drops an editor straight into the row, mounted+in the same tick (measured 0ms), focused with the text selected. Double-clicking+upgrades to a full editor covering every field, carrying whatever was already+typed across, so the fast path never pays for the slow one. Deferring the single+click to wait out a possible double would have made plain renaming feel sluggish,+which was the one thing the brief ruled out.++Modifier clicks keep their old meaning: ctrl and shift still select and never+start an edit. The editor is positioned inside the row's own box so the name+column cannot resize, the same rule the table cells follow.++**Dropping a task across a lane boundary changes its lane.** On the chart the+group header above a row IS its lane, so a row that lands under a different header+has moved lanes, and the bar recolours to match. The phase restore is folded into+the order snapshot already being pushed, so the whole gesture stays one Ctrl+Z.++**Dragging a lane collapses every lane for the duration.** Choosing a position+among five headers is easy; choosing one among seventy rows means aiming at+something off-screen. The prior collapse state is captured before and restored+exactly on drop or cancel, and the transient state is never written to prefs.++**Collapse is view state**, in prefs beside theme and grain, because it is how one+person is looking at the plan rather than a fact about it. The chevron sits at the+far right of each header with the row count, and its tooltip says the collapse is+yours alone. If it should ever be shared it moves to the sidecar and the copy+changes with it.++Collapsing hides rows, not time: the axis is a function of dates and does not+move, and Fit still fits the whole span. Everything that measures rows had to+learn about it: dependency arrows skip a collapsed endpoint rather than drawing+from nowhere, the milestone cap collision layout ignores hidden caps and their+dashed rules go with them, and the tailroom measures the last VISIBLE row.+Verified: 67 rows to 42, arrows 56 to 31, caps 9 to 8, zero cap overlaps, zero+orphaned arrows, and an exact restore on expand.++**Two temporal-dead-zone bugs, the same shape as 0.11.0's.** The collapse block+was first placed after the chart build, so `applyCollapse()` ran before `const+collapsed` existed and the module threw straight after the row loop: the labels+were in the DOM but the bars container had never been appended, which is why the+symptom looked like missing bars rather than an error. Moving it up hit the same+wall against `prefs`. It now sits directly below the prefs block. This file has a+long module-level prologue and that is now twice it has bitten; anything the+prologue touches must be declared above it.+ ## Build notes, 2026-08-07 (0.13.0)  **One click leaves a usable editor, not just a rendered one.** Selects and the
gantt.html+274−14
@@ -455,6 +455,23 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08   table.data-table { min-width: 0; } } +/* ── Collapse a lane ──+   Collapsing hides rows, not time: the axis is a function of dates and does not+   move. Both columns hide the same rows or they drift apart as you scroll. */+.label-row.row-collapsed, .bar-row.row-collapsed, .ms-line.row-collapsed { display: none !important; }+.label-row.group { position: relative; padding-right: 4px; }+.label-row.group .lane-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.lane-toggle { display: flex; align-items: center; gap: 5px; background: none; border: none; color: var(--text2); cursor: pointer; padding: 2px 4px; border-radius: 4px; font-family: 'JetBrains Mono', monospace; font-size: calc(9px * var(--fs)); flex-shrink: 0; }+.lane-toggle:hover { background: var(--row-hover); color: var(--text); }+.lane-toggle svg { width: 11px; height: 11px; transition: transform 0.15s; }+.lane-toggle.collapsed svg { transform: rotate(-90deg); }+.lane-toggle .lane-count { color: var(--text3); }+.lane-toggle.collapsed .lane-count { color: var(--accent); }+/* Editing a name straight in the label column. Fixed to the row's own box so the+   name column never resizes, same rule the table cells follow. */+.label-edit { position: absolute; inset: 3px 6px; width: auto; background: var(--bg); border: 1px solid var(--accent); border-radius: 4px; color: var(--text); font-family: 'Satoshi', sans-serif; font-size: calc(12px * var(--fs)); padding: 0 6px; outline: none; z-index: 12; box-sizing: border-box; }+.label-row { position: relative; }+ .drop-indicator { position: absolute; left: 0; right: 0; height: 2px; background: var(--accent); z-index: 20; pointer-events: none; border-radius: 1px; box-shadow: 0 0 6px rgba(0,184,176,0.4); display: none; } .drop-indicator.show { display: block; } .label-row.dragging, .bar-row.dragging { opacity: 0.4; }@@ -621,6 +638,7 @@ function msKey(r){ return rowOrderKey(r); } // and "this key no longer resolves" must not look the same. const deletedKeys = new Set(); + // Four states. The old vocabulary is aliased on READ, everywhere, so stored data // (the data file, an example, a sidecar the user is mid-edit in) keeps working // untouched and only starts writing the new words when it is next saved.@@ -709,7 +727,7 @@ function savedPrefs(){ // step buttons have somewhere definite to land. const FS_STEPS = [0.85, 0.925, 1, 1.1, 1.2, 1.35, 1.5]; const prefs = Object.assign(-  { theme: 'dark', msStyle: 'line', granularity: 'month', showToday: true, view: 'gantt', fs: 1 },+  { theme: 'dark', msStyle: 'line', granularity: 'month', showToday: true, view: 'gantt', fs: 1, collapsed: [] },   viewDefaults(),   savedPrefs() );@@ -725,6 +743,50 @@ applyTheme(); applyView(); applyFontScale(); +// ── Collapse a lane ──+//+// View state, kept in prefs beside theme and grain: it is how one person is+// looking at the plan, not a fact about the plan. If it should ever be shared it+// gets promoted to the sidecar, and the UI says so.+const collapsed=new Set((prefs.collapsed||[]).filter(n=>typeof n==='number'));+let transientCollapse=null;          // set while a lane is being dragged+function isCollapsed(ph){ return collapsed.has(ph); }+function laneRows(ph){ return items.filter(r=>r.type!=='group'&&r.phase===ph); }+function applyCollapse(){+  items.forEach(r=>{+    if(r.type==='group'){+      const on=isCollapsed(r.phase);+      if(r._laneBtn){+        r._laneBtn.classList.toggle('collapsed',on);+        const n=laneRows(r.phase).length;+        r._laneBtn.querySelector('.lane-count').textContent=String(n);+        r._laneBtn.title=(on?'Show':'Hide')+' the '+n+' row(s) in this lane. This is your view only, it does not change the plan.';+        r._laneBtn.setAttribute('aria-expanded',on?'false':'true');+      }+      return;+    }+    const hide=isCollapsed(r.phase);+    r._collapsed=hide;+    if(r._lDiv)r._lDiv.classList.toggle('row-collapsed',hide);+    if(r._bDiv)r._bDiv.classList.toggle('row-collapsed',hide);+    // the dashed rule belongs to the milestone, so it goes with it+    if(r._msLine)r._msLine.classList.toggle('row-collapsed',hide);+  });+}+function setCollapsed(ph,on,persist){+  if(on)collapsed.add(ph); else collapsed.delete(ph);+  if(persist!==false){ prefs.collapsed=[...collapsed]; savePrefs(); }+  applyCollapse();+  layoutMsCaps();sizeTailroom();drawDeps();+}+labelsEl.addEventListener('click',e=>{+  const btn=e.target.closest('.lane-toggle');+  if(!btn)return;+  e.stopPropagation();+  const g=items.find(r=>r._laneBtn===btn);+  if(g)setCollapsed(g.phase,!isCollapsed(g.phase));+});+ // ── Date range ── // The timeline is derived from the data every time, so shifting the plan reshapes // the axis for free. ORIGINAL_START is the untouched data-file start, which is what@@ -1039,7 +1101,15 @@ function moveRowToPhase(r,np){ function undo(){   if(!undoStack.length)return;   const sn=undoStack.pop();-  if(sn.type==='order'){applyOrder(sn.order);}+  if(sn.type==='order'){+    (sn.phases||[]).forEach(pc=>{+      pc.r.phase=pc.from;+      if(pc.r.id&&barEls[pc.r.id])barEls[pc.r.id].bar.style.background=phaseColor(pc.from);+      if(pc.r._msLine)pc.r._msLine.style.color=phaseColor(pc.from);+    });+    applyOrder(sn.order);+    if(sn.phases&&sn.phases.length){applyCollapse();applyFilter();refreshChartGeometry();}+  }   else if(sn.type==='delete'){restoreRow(sn.r,sn.idx,sn.droppedDeps);}   else if(sn.type==='create'){removeRow(sn.r);renderTable();}   else if(sn.type==='phase'){@@ -1422,7 +1492,15 @@ depSvg=document.createElementNS('http://www.w3.org/2000/svg','svg');depSvg.class // almost-identical path that drifts. function buildRowDom(r,idx){   const lDiv=document.createElement('div'),bDiv=document.createElement('div');-  if(r.type==='group'){lDiv.className='label-row group';lDiv.textContent=groupLabel(r.label);bDiv.className='bar-row group';}+  if(r.type==='group'){+    lDiv.className='label-row group';+    const nm=document.createElement('span');nm.className='lane-name';nm.textContent=groupLabel(r.label);+    const btn=document.createElement('button');btn.className='lane-toggle';btn.type='button';+    btn.innerHTML='<span class="lane-count"></span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>';+    lDiv.appendChild(nm);lDiv.appendChild(btn);+    r._laneBtn=btn;+    bDiv.className='bar-row group';+  }   else if(r.type==='milestone'){     lDiv.className='label-row ms';lDiv.textContent=r.name;bDiv.className='bar-row ms-row';     const x=toX(r.date);@@ -1450,6 +1528,7 @@ items.forEach((r,idx)=>buildRowDom(r,idx));  // Appended after the rows so the dashed rules sit above the phase bands. msLineEls.forEach(ln=>bars.appendChild(ln));+applyCollapse(); document.documentElement.setAttribute('data-ms', prefs.msStyle); if(prefs.showToday===false)todayL.classList.add('off'); @@ -1517,7 +1596,7 @@ function relayout(){ function layoutMsCaps(){   if(prefs.msStyle!=='line')return;   const caps=[];-  items.filter(r=>r.type==='milestone'&&r._msLine).forEach(r=>{+  items.filter(r=>r.type==='milestone'&&r._msLine&&!r._collapsed).forEach(r=>{     const cap=r._msLine.querySelector('.ms-cap');     if(cap){cap.style.top='2px';caps.push({cap,x:toX(r.date)});}   });@@ -1557,7 +1636,8 @@ function keepTailLast(){ } function sizeTailroom(){   const pane=timelineEl.clientHeight||0;-  const lastRow=items.length?items[items.length-1]._bDiv:null;+  const vis=items.filter(r=>r._bDiv&&!r._collapsed);+  const lastRow=vis.length?vis[vis.length-1]._bDiv:null;   const rowH=lastRow?lastRow.offsetHeight:34;   const pad=Math.max(0,Math.round(pane/2-rowH));   barTail.style.height=pad+'px';@@ -1586,6 +1666,7 @@ if(window.ResizeObserver)new ResizeObserver(scheduleRelayout).observe(timelineEl // 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();}); + // ── Filter ── // The legend's phase and milestone toggles are a statement about the plan, not // about the chart, so the table honours them too and the two lenses agree.@@ -1596,7 +1677,13 @@ function sb0(entry){ return entry.row || entry.bar.closest('.bar-row'); } function drawDeps(){   depSvg.innerHTML='';let maxY=0;   items.forEach(r=>{if(!r.deps||!r.id)return;const tgt=barEls[r.id];if(!tgt)return;-    r.deps.forEach(depId=>{const src=barEls[depId];if(!src)return;if(r._bDiv.classList.contains('dim')||items[src.idx]._bDiv.classList.contains('dim'))return;+    r.deps.forEach(depId=>{const src=barEls[depId];if(!src)return;+      const sItem=items[src.idx];+      if(!sItem)return;+      if(r._bDiv.classList.contains('dim')||sItem._bDiv.classList.contains('dim'))return;+      // a collapsed row has no position on screen, so an arrow to it would be+      // drawn from nowhere to nowhere+      if(r._collapsed||sItem._collapsed)return;       // 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);@@ -1639,17 +1726,78 @@ function doSelect(id, e){ function updateSelCount(){if(selected.size>1){selCountEl.textContent=`${selected.size} selected`;selCountEl.classList.add('show');}else{selCountEl.classList.remove('show');}}  // ── Label click → select bar ──+// A click on the label edits the NAME, right there. The bar still owns the+// schedule, so clicking it opens the date and dependency editor as before: the+// label is the thing's name, the bar is its time.+//+// Multi-select modifiers keep their old meaning and never start an edit. labelsEl.addEventListener('click',e=>{-  if(reorderState)return;-  const row=e.target.closest('.label-row[data-id]');+  if(reorderState||laneDrag)return;+  if(e.target.closest('.lane-toggle'))return;+  const row=e.target.closest('.label-row[data-id], .label-row.ms');+  if(!row)return;+  const r=items.find(x=>x._lDiv===row);+  if(!r)return;+  if(e.ctrlKey||e.metaKey||e.shiftKey){+    if(!r.id)return;+    doSelect(r.id,e);+    if(selected.size>1)openBatchEditor(); else closeEditor();+    return;+  }+  editLabelInline(r);+});+// The second click of a double-click upgrades to the full editor, carrying+// whatever has been typed so far, so the fast path is never paid for by the+// slow one: the inline editor is already mounted and instant.+labelsEl.addEventListener('dblclick',e=>{+  const row=e.target.closest('.label-row[data-id], .label-row.ms');   if(!row)return;-  const id=row.dataset.id;-  doSelect(id, e);-  if(selected.size===1)openSingleEditor(id);-  else if(selected.size>1)openBatchEditor();-  else closeEditor();+  const r=items.find(x=>x._lDiv===row);+  if(!r)return;+  e.preventDefault();e.stopPropagation();+  const live=row.querySelector('.label-edit');+  const carried=live?live.value:undefined;+  if(live)live.__cancel&&live.__cancel();+  openItemModal(r,carried); }); +function editLabelInline(r){+  const lDiv=r._lDiv;+  if(!lDiv||lDiv.querySelector('.label-edit'))return;+  const inp=document.createElement('input');+  inp.type='text';inp.className='label-edit';inp.value=r.name||'';+  let done=false;+  const restore=()=>{+    if(r.type==='milestone')refreshMsChrome(r); else refreshLabelRow(r);+    applyCollapse();+  };+  const finish=save=>{+    if(done)return;done=true;+    const v=inp.value.trim();+    try{inp.blur();}catch(e){}+    if(save&&v&&v!==r.name){+      if(r.type==='milestone')snapshotMs(r); else snapshot(r.id);+      r.name=v;+      restore();+      if(r.type==='milestone')updateMilestone(r); else updateBar(r.id);+      refreshChartGeometry();+      scheduleSave('label-edit');+    } else restore();+  };+  inp.__cancel=()=>{done=true;restore();};+  inp.addEventListener('keydown',e=>{e.stopPropagation();+    if(e.key==='Enter'){e.preventDefault();finish(true);}+    else if(e.key==='Escape'){e.preventDefault();finish(false);}});+  inp.addEventListener('blur',()=>finish(true));+  inp.addEventListener('mousedown',e=>e.stopPropagation());+  inp.addEventListener('click',e=>e.stopPropagation());+  inp.addEventListener('dblclick',e=>e.stopPropagation());+  lDiv.appendChild(inp);+  inp.focus();inp.select();+  labelEditing=inp;+}+let labelEditing=null;+ // ── Unified pending gesture (vertical=reorder, horizontal=move) ── let reorderState=null, gesture=null; const labelDropInd=document.createElement('div');labelDropInd.className='drop-indicator';labelsEl.appendChild(labelDropInd);@@ -1956,7 +2104,30 @@ document.addEventListener('mouseup',()=>{       const finalInsert=reorderState.insertBefore?actualInsert:actualInsert+1;       remainingItems.splice(finalInsert,0,...moveItems);       items.length=0;items.push(...remainingItems);-      reflowRows();+      // Where a row lands is what lane it is in: the group header above it is the+      // lane. Dropping across a header therefore changes the phase, and the bar+      // recolours to match, rather than leaving a row sitting under a header it+      // does not belong to.+      const phaseChanges=[];+      moveItems.forEach(mi=>{+        const at=items.indexOf(mi);+        let g=null;+        for(let i=at;i>=0;i--){ if(items[i].type==='group'){g=items[i];break;} }+        if(g&&g.phase!==undefined&&g.phase!==mi.phase){+          phaseChanges.push({r:mi,from:mi.phase});+          mi.phase=g.phase;+          if(mi.id&&barEls[mi.id])barEls[mi.id].bar.style.background=phaseColor(g.phase);+          if(mi._msLine)mi._msLine.style.color=phaseColor(g.phase);+        }+      });+      if(phaseChanges.length){+        // fold the phase restore into the order snapshot already pushed, so the+        // whole gesture is still one Ctrl+Z+        const top=undoStack[undoStack.length-1];+        if(top&&top.type==='order')top.phases=phaseChanges;+        moveHint=moveItems.length===1?'"'+(moveItems[0].name||'a row')+'"':moveItems.length+' rows';+      }+      applyCollapse();reflowRows();applyFilter();refreshChartGeometry();renderTable();       drawDeps();scheduleSave('drag-reorder');didDrag=true;     }     reorderState=null;@@ -2187,6 +2358,80 @@ $('grainSel').addEventListener('change',function(){   rebuildTimeline(); }); +// ── The full item editor ──+//+// Everything about one row in one panel: the same fields the create panel offers,+// on an existing item. Reached by double-clicking a label, which upgrades from+// the inline name editor and carries whatever was already typed.+function openItemModal(r,carriedName){+  closeEditor();+  const ms=r.type==='milestone';+  const p=provOf(r)||{label:'',url:''};+  editorEl.innerHTML=`<div class="drag-bar"></div>+    <div class="batch-title">Edit ${ms?'milestone':'task'}</div>+    <div class="de-note-row"><label>name <span class="de-hint">required</span></label><input type="text" id="imName"></div>+    <div class="de-row" style="margin-top:8px">+      <div style="flex:1"><label>phase</label><select id="imPhase">${phases.map((ph,i)=>`<option value="${i}">${esc(ph.name)}</option>`).join('')}</select></div>+    </div>+    <div class="de-row">+      ${ms?'':'<div><label>start</label><input type="date" id="imStart"></div>'}+      <div><label>${ms?'date':'deadline'}</label><input type="date" id="imEnd"></div>+    </div>+    <div class="de-row">+      <div style="flex:1"><label>owner</label><input type="text" id="imOwner" list="ownerList" placeholder="one name"></div>+      <div style="flex:1"><label>status</label><select id="imStatus"><option value="">none</option>${STATUS_ORDER.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('')}</select></div>+    </div>+    <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>+    <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||'');+  $('imPhase').value=String(r.phase);+  if(!ms&&$('imStart'))$('imStart').value=r.start||'';+  $('imEnd').value=(ms?r.date:r.end)||'';+  $('imOwner').value=r.owner||'';+  $('imStatus').value=statusOf(r);+  $('imDesc').value=r.description||'';+  $('imProv').value=p.label||'';+  const rect=(r._lDiv||document.body).getBoundingClientRect();+  editorEl.style.left=Math.max(8,Math.min(rect.right+12,window.innerWidth-340))+'px';+  editorEl.style.top=Math.max(8,Math.min(rect.top,window.innerHeight-430))+'px';+  editorEl.classList.add('show');+  $('imName').focus();$('imName').select();+  editorEl.addEventListener('keydown',e=>{e.stopPropagation();+    if(e.key==='Enter'&&e.target.tagName!=='BUTTON'){e.preventDefault();$('imApply').click();}+    else if(e.key==='Escape'){e.preventDefault();closeEditor();}});+  $('imCancel').addEventListener('click',closeEditor);+  $('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;}+    if(ms)snapshotMs(r); else snapshot(r.id);+    const np=parseInt($('imPhase').value,10);+    r.name=name;+    const end=$('imEnd').value;+    if(ms){ if(end)r.date=end; }+    else{+      const st=$('imStart')?$('imStart').value:r.start;+      if(st)r.start=st;+      if(end)r.end=end;+      if(r.start&&r.end&&r.start>r.end)r.start=r.end;+    }+    setOpt(r,'owner',$('imOwner').value.trim());+    setOpt(r,'status',STATUS_ORDER.includes($('imStatus').value)?$('imStatus').value:'');+    setOpt(r,'description',$('imDesc').value.trim());+    const pl=$('imProv').value.trim();+    if(!pl)delete r.provenance;+    else r.provenance=p.url?{label:pl,url:p.url}:pl;+    if(ms){refreshMsChrome(r);updateMilestone(r);} else {updateBar(r.id);refreshLabelRow(r);}+    if(phases[np]&&np!==r.phase)moveRowToPhase(r,np);+    applyCollapse();refreshChartGeometry();renderTable();+    closeEditor();+    scheduleSave('item-editor');+  });+}+ // ── Create a row ── // // The same inline editor panel the milestone editor uses, so creating a row and@@ -2679,10 +2924,18 @@ function laneBlock(g){   while(end<items.length&&items[end].type!=='group')end++;   return {gi,end,rows:items.slice(gi,end)}; }+function restoreTransientCollapse(){+  if(!transientCollapse)return;+  collapsed.clear();+  transientCollapse.forEach(n=>collapsed.add(n));+  transientCollapse=null;+  applyCollapse();layoutMsCaps();sizeTailroom();drawDeps();+} function endLaneDrag(commit){   if(!laneDrag)return;   clearTimeout(laneDrag.timer);   const d=laneDrag; laneDrag=null;+  restoreTransientCollapse();   document.body.classList.remove('row-dragging');   labelDropInd.classList.remove('show');barDropInd.classList.remove('show');   (d.block?d.block.rows:[]).forEach(r=>{if(r._lDiv)r._lDiv.classList.remove('dragging');if(r._bDiv)r._bDiv.classList.remove('dragging');});@@ -2715,6 +2968,13 @@ labelsEl.addEventListener('mousedown',e=>{     if(!laneDrag)return;     laneDrag.armed=true;     laneDrag.block=laneBlock(g);+    // Collapse every lane for the duration. Moving a lane means choosing a place+    // among the other lanes, and with 70 rows expanded the place you are aiming+    // for is usually off-screen. This is transient: the exact prior state is+    // restored on drop or cancel, and it is never written to prefs.+    transientCollapse=[...collapsed];+    phases.forEach((p,i)=>collapsed.add(i));+    applyCollapse();layoutMsCaps();sizeTailroom();drawDeps();     (laneDrag.block?laneDrag.block.rows:[]).forEach(r=>{if(r._lDiv)r._lDiv.classList.add('dragging');if(r._bDiv)r._bDiv.classList.add('dragging');});     document.body.classList.add('row-dragging');     hideTip();
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.13.0",+  "version": "0.14.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.13.0",+  "version": "0.14.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+}