← Commit history

0.30.0: the sections editor

Drew Owens ·538919841c · ·parent 832fa02
5 files changed +378−4
README.md+6
@@ -129,6 +129,12 @@ so renaming a header renames the phase and the change shows up in the legend, th table's phase column and every phase dropdown at once. Dragging a lane reorders `phases[]` itself, which is why the legend and the chart can no longer disagree. +The **Sections** button in the top bar opens the editor: add a section, rename it,+give it an aside, recolour it from a brand-safe palette (or a `#rrggbb` of your+own), reorder it, or remove it. An empty section goes on its own; one with rows in+it makes you say where they land, so nothing is ever orphaned. Every change is one+Ctrl+Z and one changelog line.+ A `lead` is the short aside beside a section name ("the long pole (Adonis)"), shown as secondary text. Older data files that still carry `type: "group"` rows load normally: each is absorbed into its phase, contributing its aside as the `lead`.
docs/BUILD-NOTES.md+52
@@ -3,6 +3,58 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-14 (0.30.0, the sections editor)++0.29.0 made `phases[]` the truth about sections; this puts a door on it. A+Sections button in the actions row of both lenses opens a modal listing the phases+in order with a colour swatch, the name, its aside and its row count, and every+button in it calls one of the mutation functions from 0.29.0 and then rebuilds the+list from `phases[]`. There is no second copy of the section list to fall out of+step, which is the entire dividend of doing unification first.++**One snapshot type for create, delete, recolour and lead.** All four are "the+section list looked like this and every row pointed where it pointed", so+`phaseSnapshot()` captures both halves together. That is what makes undoing a+DELETE put the rows back in the section they came from, instead of leaving them+where they were reassigned to, which is the failure a per-field undo would have+shipped with.++**A populated section cannot just go.** Empty deletes freely; a section with rows+in it makes you pick where they land, in a dropdown that cannot offer "nowhere",+and the rows move before the section is removed. One changelog line names the+count and the destination. Every index above the removed one shifts down by one,+so the item phases, the collapse set and the legend visibility are all remapped in+that same function, exactly as they are on a reorder.++**A new section gets its id from the server**, like every row: `POST /uid`, never+minted in the browser, and the create is refused with the reason in the modal if+the call cannot be made.++**Colour: the palette is eight swatches and the first one is "Theme".** Theme is+the ABSENCE of a colour, handing the lane back to the `--ph` ramp, which is the+only setting that adapts between light and dark. Anything explicit is the colour+the person picked and stays that colour in both themes and in print, which is the+model 0.29.0 stated and this makes reachable. A constrained hex box takes anything+else and refuses what is not `#rrggbb`.++**Three DOM bugs, all from the same root: derived rows still need real DOM.**+`buildDerivedHeaders` rebuilt every header object from scratch, so at runtime each+one lost its `_lDiv` and the next reflow called `appendChild(undefined)`; headers+now keep their object and their DOM across a rebuild, matched by phase uid.+Building that DOM at init as well as in `buildRows` left an orphaned node per+header in the label column, so the chart grew a second set of headers, fifteen+where there should have been eight; the build is now guarded on whether the+initial pass has run. And `applyPhaseOrder` did not re-point the header objects at+their new phase indices, so a reorder moved the legend and left the chart alone.+All three were caught by the suite rather than by reading, which is the argument+for the suite.++Ten suites green, zero JS errors: keyboard, columns, dependencies, uid,+milestone-selection, New-panel memory, select-list, milestone caps, unification+and sections, plus the new 39-check modal sweep covering the button in both+lenses, create, recolour, rename, lead, reorder, delete-empty, delete-with-+reassign, undo and Escape.+ ## Build notes, 2026-08-14 (0.29.0, sections and phases are one thing)  The user found the seam himself: group header rows were seeded display items in
gantt.html+317−1
@@ -511,6 +511,32 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .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; }+.sec-back { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 99990; display: flex; align-items: center; justify-content: center; }+.sec-modal { width: min(620px, 94vw); max-height: 84vh; display: flex; flex-direction: column; background: var(--overlay); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 18px 48px rgba(0,0,0,0.5); }+.sec-head { display: flex; align-items: baseline; gap: 10px; padding: 14px 16px 10px; border-bottom: 1px solid var(--border-muted); }+.sec-head h2 { margin: 0; font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 600; letter-spacing: -0.01em; }+.sec-head .sec-hint { font-size: 11px; color: var(--text2); }+.sec-list { overflow-y: auto; padding: 8px 10px; }+.sec-row { display: flex; align-items: center; gap: 8px; padding: 7px 6px; border-radius: 7px; border: 1px solid transparent; }+.sec-row:hover { background: var(--row-hover); }+.sec-row.sec-editing { border-color: var(--border); background: var(--surface); }+.sec-sw { width: 15px; height: 15px; border-radius: 4px; border: 1px solid var(--border); flex-shrink: 0; cursor: pointer; padding: 0; }+.sec-name { flex: 1; min-width: 0; font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.sec-name .sec-lead { color: var(--text3); font-size: 0.86em; margin-left: 7px; }+.sec-count { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); flex-shrink: 0; min-width: 44px; text-align: right; }+.sec-acts { display: flex; gap: 2px; flex-shrink: 0; }+.sec-btn { background: none; border: 1px solid transparent; color: var(--text2); cursor: pointer; border-radius: 5px; padding: 3px 6px; font-size: 10px; font-family: inherit; }+.sec-btn:hover { background: var(--elevated); color: var(--text); border-color: var(--border-muted); }+.sec-btn[disabled] { opacity: 0.35; cursor: default; }+.sec-btn.danger:hover { color: #f85149; }+.sec-foot { padding: 10px 16px 14px; border-top: 1px solid var(--border-muted); display: flex; gap: 8px; align-items: center; }+.sec-foot input[type="text"] { flex: 1; min-width: 0; }+.sec-modal input[type="text"] { background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 5px 8px; font-family: inherit; font-size: 12px; }+.sec-modal select { background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 5px 8px; font-family: inherit; font-size: 12px; }+.sec-pal { display: flex; gap: 5px; flex-wrap: wrap; padding: 6px 2px 2px; }+.sec-chip { width: 18px; height: 18px; border-radius: 5px; border: 2px solid transparent; cursor: pointer; padding: 0; }+.sec-chip.on { border-color: var(--text); }+.sec-err { color: #f85149; font-size: 11px; padding: 0 16px 8px; } .lane-lead { color: var(--text3); font-weight: 400; font-style: normal; margin-left: 7px; font-size: 0.86em; } .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); }@@ -1121,6 +1147,7 @@ $('hdr').innerHTML = `     <button class="act-btn" id="btnMsStyle" title="Milestone style: diamond or dashed line">       <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>     <button class="act-btn" id="btnImport" title="Import a plan: JSON, CSV, MS Project XML or Jira CSV"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg><span class="btn-label">Import</span></button>     <button class="act-btn" id="btnExport" title="Export this plan: JSON, CSV, SVG or a self-contained HTML snapshot"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21V9m0 0l4 4m-4-4l-4 4M4 7V5a2 2 0 012-2h12a2 2 0 012 2v2"/></svg><span class="btn-label">Export</span></button>     <button class="act-btn" id="btnHelp" style="font-family:'Familjen Grotesk',sans-serif;font-size:14px;font-weight:600;padding:4px 9px">?</button>@@ -1208,10 +1235,30 @@ function absorbLegacyGroups(){ // section that shows its own header with a count of 0 is what makes a section the // user just made feel real. They are in-memory only and never saved. function buildDerivedHeaders(){+  // Reuse the header object a phase already has, DOM and all. Rebuilding them all+  // from scratch at runtime left every header without a `_lDiv`, and the next+  // reflow tried to appendChild(undefined): headers only ever got their DOM from+  // the initial buildRows pass, so a section created after load had none.+  const old=items.filter(isDerivedGroup);+  const byPhaseUid=new Map();+  old.forEach(h=>{ if(h._phUid)byPhaseUid.set(h._phUid,h); });   for(let i=items.length-1;i>=0;i--) if(isDerivedGroup(items[i])) items.splice(i,1);+  const kept=new Set();   phases.forEach((p,i)=>{-    items.push({type:'group',phase:i,label:p.name,lead:p.lead||'',uid:headerUid(i),_derived:true});+    const key=p.uid||('#ph'+i);+    let h=byPhaseUid.get(key);+    if(h){ h.phase=i; h.label=p.name; h.lead=p.lead||''; h.uid=headerUid(i); }+    else h={type:'group',phase:i,label:p.name,lead:p.lead||'',uid:headerUid(i),_derived:true,_phUid:key};+    h._phUid=key;+    kept.add(h);+    items.push(h);+    if(rowsBuilt&&!h._lDiv)buildRowDom(h,items.length-1);   });+  // a header whose phase is gone takes its DOM with it+  old.forEach(h=>{ if(!kept.has(h)){+    if(h._lDiv&&h._lDiv.parentNode)h._lDiv.remove();+    if(h._bDiv&&h._bDiv.parentNode)h._bDiv.remove();+  }}); } // Put each header immediately above the first row of its lane, and an empty lane's // header after the previous lane's block, so the chart reads as one section after@@ -1572,6 +1619,7 @@ function applySnapshot(sn){   }   // A list of snapshots applied as one. The inverse is the inverses in reverse, so   // undoing a batch and redoing it both land exactly where they started.+  if(sn.type==='phase.state')return restorePhaseState(sn);   if(sn.type==='phase.rename'){     const p=phases[sn.i]; if(!p)return null;     const inv={type:'phase.rename',i:sn.i,name:p.name};@@ -1840,6 +1888,9 @@ document.addEventListener('keydown',e=>{     return;   }   if(rowDrag||laneDrag){ endRowDrag(false); endLaneDrag(false); return; }+  // The sections modal owns Escape while it is up; its own handler runs first and+  // stops this one, so reaching here means it is already closed.+  if(secModal){ closeSections(); return; }   // Innermost first here too: the half-made dependency goes before the mode that   // is making it.   if(depMode&&depPending){ setDepPending(null); return; }@@ -2409,8 +2460,10 @@ function buildRowDom(r,idx){     b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle left'}));b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle right'}));bDiv.appendChild(b);barEls[r.id]={bar:b,idx,row:bDiv};}   r._lDiv=lDiv;r._bDiv=bDiv;r._itemIdx=idx;labelsEl.appendChild(lDiv);bars.appendChild(bDiv); }+let rowsBuilt=false; function buildRows(){ items.forEach((r,idx)=>buildRowDom(r,idx));+rowsBuilt=true;  // Appended after the rows so the dashed rules sit above the phase bands. msLineEls.forEach(ln=>bars.appendChild(ln));@@ -2976,15 +3029,277 @@ function applyPhaseOrder(arr,map){   const nv=phases.map(()=>true);   map.forEach((ni,oi)=>{ if(vis[oi]!==undefined)nv[ni]=vis[oi]; });   phaseVis.length=0; phaseVis.push(...nv);+  // The header objects still carry the phase indices they had BEFORE the remap, so+  // they are re-pointed at their phases (by phase uid, which did not move) before+  // the layout runs. Without this the legend reordered and the chart did not.+  buildDerivedHeaders();   placeHeaders();   reflowRows(); refreshSections(); applyFilter(); refreshChartGeometry(); }+// One snapshot type covers create, delete, recolour and lead, because all four+// are "the section list looked like this, and every row pointed where it pointed".+// Capturing both halves together is what makes an undo of a DELETE put the rows+// back in the section they came from rather than leaving them where they were+// reassigned to.+function phaseSnapshot(){+  return {type:'phase.state',+    phases:phases.map(p=>Object.assign({},p)),+    at:items.filter(r=>!isDerivedGroup(r)).map(r=>[r.uid,r.phase]),+    collapsed:[...collapsed], vis:phaseVis.slice()};+}+function restorePhaseState(sn){+  const inv=phaseSnapshot();+  phases.length=0; phases.push(...sn.phases.map(p=>Object.assign({},p)));+  const m=new Map(sn.at);+  items.forEach(r=>{ if(!isDerivedGroup(r)&&m.has(r.uid))r.phase=m.get(r.uid); });+  collapsed.clear(); (sn.collapsed||[]).forEach(x=>collapsed.add(x));+  prefs.collapsed=[...collapsed]; savePrefs();+  phaseVis.length=0; phaseVis.push(...(sn.vis&&sn.vis.length===phases.length?sn.vis:phases.map(()=>true)));+  buildDerivedHeaders(); placeHeaders(); reflowRows();+  refreshSections(); applyFilter(); refreshChartGeometry();+  return inv;+}+// A section is a row-shaped thing, so it gets its id the same way a row does:+// from the server, never minted here. If the call cannot be made the section is+// not created and the modal says so, exactly as the New panel does.+async function createPhase(name,color,lead){+  const uid=await allocUid();+  const sn=phaseSnapshot();+  const p={name,start:'',end:'',uid};+  if(color)p.color=color;+  if(lead)p.lead=lead;+  phases.push(p); phaseVis.push(true);+  pushUndo(sn);+  buildDerivedHeaders(); placeHeaders(); reflowRows();+  refreshSections(); applyFilter(); refreshChartGeometry();+  logPhaseEvent({action:'phase.create',phase:name,to:{uid,color:color||'',lead:lead||''}});+  scheduleSave('section-create');+  return phases.length-1;+}+// Deleting a section never orphans a row. An empty one goes on its own; a+// populated one MUST name where its rows are going, and they are moved before the+// section is removed. Every index above the removed one shifts down by one, which+// is why the item phases, the collapse set and the legend visibility are all+// remapped here rather than anywhere else.+function deletePhase(i,reassignTo){+  const p=phases[i]; if(!p||phases.length<2)return false;+  const rows=items.filter(r=>!isDerivedGroup(r)&&r.phase===i);+  if(rows.length&&(reassignTo===undefined||reassignTo===null||reassignTo===i||!phases[reassignTo]))return false;+  const sn=phaseSnapshot();+  const name=p.name, dest=rows.length?phases[reassignTo].name:null;+  rows.forEach(r=>{ r.phase=reassignTo; });+  const map=new Map();+  phases.forEach((_,oi)=>{ if(oi<i)map.set(oi,oi); else if(oi>i)map.set(oi,oi-1); });+  phases.splice(i,1);+  items.forEach(r=>{ if(!isDerivedGroup(r)&&map.has(r.phase))r.phase=map.get(r.phase); });+  const wasC=[...collapsed]; collapsed.clear();+  wasC.forEach(ph=>{ if(map.has(ph))collapsed.add(map.get(ph)); });+  prefs.collapsed=[...collapsed]; savePrefs();+  const vis=phaseVis.slice(); const nv=phases.map(()=>true);+  map.forEach((ni,oi)=>{ if(vis[oi]!==undefined)nv[ni]=vis[oi]; });+  phaseVis.length=0; phaseVis.push(...nv);+  pushUndo(sn);+  buildDerivedHeaders(); placeHeaders(); reflowRows();+  refreshSections(); applyFilter(); refreshChartGeometry();+  logPhaseEvent({action:'phase.delete',phase:name,count:rows.length,+                 to:dest?{section:dest}:undefined});+  scheduleSave('section-delete');+  return true;+}+function recolorPhase(i,color){+  const p=phases[i]; if(!p)return false;+  const from=p.color||'';+  if((color||'')===from)return false;+  pushUndo(phaseSnapshot());+  if(color)p.color=color; else delete p.color;+  items.forEach(r=>{+    if(r.phase!==i)return;+    if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(i);+    if(r._msLine)r._msLine.style.color=phaseColor(i);+  });+  refreshSections();+  logPhaseEvent({action:'phase.color',phase:p.name,from:{color:from},to:{color:color||'(theme ramp)'}});+  scheduleSave('section-color');+  return true;+}+function setPhaseLead(i,lead){+  const p=phases[i]; if(!p)return false;+  const from=p.lead||'';+  if((lead||'')===from)return false;+  pushUndo(phaseSnapshot());+  if(lead)p.lead=lead; else delete p.lead;+  refreshSections();+  logPhaseEvent({action:'phase.lead',phase:p.name,from:{lead:from},to:{lead:lead||''}});+  scheduleSave('section-lead');+  return true;+} function logPhaseEvent(e){   if(SNAPSHOT)return;   try{ fetch('log',{method:'POST',headers:{'Content-Type':'application/json'},     body:JSON.stringify({actor:'user',events:[Object.assign({via:'sections'},e)]})}).catch(()=>{}); }catch(_){} } +// ── Sections editor ────────────────────────────────────────────────────────+//+// The modal is a view of phases[] and nothing else: every button calls one of the+// mutation functions above, then the list is rebuilt from phases[]. There is no+// second copy of the section list to fall out of step, which is the whole reason+// unification came first.+//+// A brand-safe palette, plus a constrained hex box for anything else. "Theme" is+// the absence of a colour: it hands the lane back to the --ph ramp, which is the+// only setting that adapts between light and dark. Anything explicit is the colour+// the user picked and stays that colour everywhere, on purpose.+const SEC_PALETTE=[['','Theme'],['#00b8b1','Teal'],['#8c6bf7','Purple'],['#64abff','Blue'],+  ['#3fb950','Green'],['#f0883e','Orange'],['#f85149','Red'],['#8b949e','Slate']];+const HEX_RE=/^#[0-9a-fA-F]{6}$/;+let secModal=null;+function closeSections(){+  if(!secModal)return;+  secModal.remove(); secModal=null;+  document.removeEventListener('keydown',secKeys,true);+}+function secKeys(e){+  if(e.key!=='Escape'||!secModal)return;+  // Innermost first, same stack as everywhere else: an open colour picker or an+  // open name field takes the Escape before the modal does.+  const open=secModal.querySelector('.sec-row.sec-editing');+  if(open){ e.stopPropagation(); e.preventDefault(); renderSections(); return; }+  e.stopPropagation(); e.preventDefault(); closeSections();+}+function laneCount(i){ return items.filter(r=>!isDerivedGroup(r)&&r.phase===i).length; }+function openSections(){+  closeMenus(); closeEditor(); closeSections();+  const back=document.createElement('div'); back.className='sec-back';+  back.innerHTML='<div class="sec-modal" role="dialog" aria-modal="true" aria-label="Sections">'++    '<div class="sec-head"><h2>Sections</h2><span class="sec-hint">the lanes of this plan, in order</span></div>'++    '<div class="sec-list"></div><div class="sec-err" style="display:none"></div>'++    '<div class="sec-foot"><input type="text" id="secNew" placeholder="New section name" maxlength="60">'++    '<button class="de-btn primary" id="secAdd">Add section</button>'++    '<button class="de-btn secondary" id="secDone">Done</button></div></div>';+  document.body.appendChild(back);+  secModal=back;+  back.addEventListener('mousedown',e=>{ if(e.target===back)closeSections(); });+  document.addEventListener('keydown',secKeys,true);+  back.querySelector('#secDone').addEventListener('click',closeSections);+  const add=async()=>{+    const inp=back.querySelector('#secNew'); const nm=inp.value.trim();+    const err=back.querySelector('.sec-err');+    if(!nm){ inp.focus(); return; }+    const btn=back.querySelector('#secAdd'); btn.disabled=true;+    try{ await createPhase(nm,'',''); inp.value=''; err.style.display='none'; renderSections(); }+    catch(e){ err.textContent='No id could be allocated, so no section was created ('+e.message+').'; err.style.display=''; }+    btn.disabled=false;+  };+  back.querySelector('#secAdd').addEventListener('click',add);+  back.querySelector('#secNew').addEventListener('keydown',e=>{ e.stopPropagation(); if(e.key==='Enter')add(); });+  renderSections();+  back.querySelector('#secNew').focus();+}+function renderSections(){+  if(!secModal)return;+  const list=secModal.querySelector('.sec-list');+  list.innerHTML='';+  phases.forEach((p,i)=>{+    const n=laneCount(i);+    const row=document.createElement('div'); row.className='sec-row'; row.dataset.i=String(i);+    row.innerHTML=`<button class="sec-sw" title="Colour" style="background:${esc(phaseColor(i))}"></button>`++      `<span class="sec-name">${esc(p.name)}${p.lead?`<span class="sec-lead">${esc(p.lead)}</span>`:''}</span>`++      `<span class="sec-count">${n} row${n===1?'':'s'}</span>`++      `<span class="sec-acts">`++        `<button class="sec-btn" data-a="up" title="Move up"${i===0?' disabled':''}>&uarr;</button>`++        `<button class="sec-btn" data-a="down" title="Move down"${i===phases.length-1?' disabled':''}>&darr;</button>`++        `<button class="sec-btn" data-a="edit" title="Rename, or set the aside beside the name">Edit</button>`++        `<button class="sec-btn danger" data-a="del" title="Remove this section"${phases.length<2?' disabled':''}>Remove</button>`++      `</span>`;+    list.appendChild(row);+  });+  list.querySelectorAll('.sec-row').forEach(row=>{+    const i=+row.dataset.i;+    row.querySelector('.sec-sw').addEventListener('click',()=>openSwatch(row,i));+    row.querySelectorAll('.sec-btn').forEach(b=>b.addEventListener('click',()=>{+      const a=b.dataset.a;+      if(a==='up'&&i>0){ reorderPhases(i,i-1); renderSections(); }+      else if(a==='down'&&i<phases.length-1){ reorderPhases(i,i+1); renderSections(); }+      else if(a==='edit') openEdit(row,i);+      else if(a==='del') openDelete(row,i);+    }));+  });+}+function openSwatch(row,i){+  row.classList.add('sec-editing');+  const acts=row.querySelector('.sec-acts');+  const cur=phases[i].color||'';+  const wrap=document.createElement('div');+  wrap.className='sec-pal'; wrap.style.cssText='width:100%;order:9';+  wrap.innerHTML=SEC_PALETTE.map(([hex,nm])=>+      `<button class="sec-chip${(cur||'')===hex?' on':''}" data-hex="${hex}" title="${esc(nm)}" style="background:${hex||'var(--ph'+((i%3)+1)+')'}"></button>`).join('')++    `<input type="text" class="sec-hex" placeholder="#rrggbb" maxlength="7" value="${esc(cur)}" style="width:88px;margin-left:6px">`;+  row.appendChild(wrap);+  wrap.querySelectorAll('.sec-chip').forEach(c=>c.addEventListener('click',()=>{+    recolorPhase(i,c.dataset.hex); renderSections();+  }));+  const hex=wrap.querySelector('.sec-hex');+  hex.addEventListener('keydown',e=>{ e.stopPropagation();+    if(e.key==='Enter'){ const v=hex.value.trim();+      if(v&&!HEX_RE.test(v)){ hex.style.borderColor='#f85149'; return; }+      recolorPhase(i,v); renderSections(); }+    else if(e.key==='Escape'){ renderSections(); } });+  hex.focus();+}+function openEdit(row,i){+  row.classList.add('sec-editing');+  const p=phases[i];+  const wrap=document.createElement('div');+  wrap.style.cssText='display:flex;gap:6px;width:100%;order:9;padding-top:6px';+  wrap.innerHTML=`<input type="text" class="sec-nm" value="${esc(p.name)}" maxlength="60" style="flex:1">`++    `<input type="text" class="sec-ld" value="${esc(p.lead||'')}" maxlength="60" placeholder="aside, e.g. the long pole (Adonis)" style="flex:1">`++    `<button class="de-btn primary sec-ok" style="padding:2px 10px">Save</button>`;+  row.appendChild(wrap);+  const nm=wrap.querySelector('.sec-nm'), ld=wrap.querySelector('.sec-ld');+  const save=()=>{+    const v=nm.value.trim();+    if(v&&v!==p.name)renamePhase(i,v);+    setPhaseLead(i,ld.value.trim());+    renderSections();+  };+  wrap.querySelector('.sec-ok').addEventListener('click',save);+  [nm,ld].forEach(el=>el.addEventListener('keydown',e=>{ e.stopPropagation();+    if(e.key==='Enter')save(); else if(e.key==='Escape')renderSections(); }));+  nm.focus(); nm.select();+}+function openDelete(row,i){+  const n=laneCount(i);+  row.classList.add('sec-editing');+  const wrap=document.createElement('div');+  wrap.style.cssText='display:flex;gap:6px;width:100%;order:9;padding-top:6px;align-items:center;flex-wrap:wrap';+  if(!n){+    wrap.innerHTML=`<span style="font-size:11px;color:var(--text2);flex:1">Remove "${esc(phases[i].name)}"? It has no rows.</span>`++      `<button class="de-btn primary sec-go" style="padding:2px 10px">Remove</button>`++      `<button class="de-btn secondary sec-no" style="padding:2px 10px">Cancel</button>`;+  } else {+    // A populated section cannot just go: its rows have to land somewhere, and the+    // user says where. There is no option that discards them.+    const opts=phases.map((p,j)=>j===i?'':`<option value="${j}">${esc(p.name)}</option>`).join('');+    wrap.innerHTML=`<span style="font-size:11px;color:var(--text2);flex:1 1 100%">`++      `"${esc(phases[i].name)}" holds ${n} row${n===1?'':'s'}. Move ${n===1?'it':'them'} to:</span>`++      `<select class="sec-dest">${opts}</select>`++      `<button class="de-btn primary sec-go" style="padding:2px 10px">Move and remove</button>`++      `<button class="de-btn secondary sec-no" style="padding:2px 10px">Cancel</button>`;+  }+  row.appendChild(wrap);+  wrap.querySelector('.sec-no').addEventListener('click',()=>renderSections());+  wrap.querySelector('.sec-go').addEventListener('click',()=>{+    const sel=wrap.querySelector('.sec-dest');+    const to=sel?parseInt(sel.value,10):undefined;+    if(deletePhase(i,to))renderSections();+  });+}+function wireSectionsButton(){+  const b=$('btnSections');+  if(b)b.addEventListener('click',e=>{ e.stopPropagation(); openSections(); });+}+ // ── Milestone editor ── function openMilestoneEditor(uid){   const r=rowByUid(uid);if(!r||r.type!=='milestone')return;@@ -5340,6 +5655,7 @@ async function init(){   wireBarDblclick();   wireTooltipsAndExport();   wireImportAndHelp();+  wireSectionsButton();   wireThemeButton();   wireMsStyleAndGrain();   wireDepMode();
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.29.0",+  "version": "0.30.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.29.0",+  "version": "0.30.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+}