← Commit history

0.17.0: the keyboard round

Drew Owens ·c194e11775 · ·parent 3c946cc
Focused row: the row whose cell editor you touched last, marked with an accent bar inset into its first cell. No new click target competing with the one-click editors, and no layout shift (the focused cell's left edge and width are identical to an unfocused one's). Esc gives it back after the field. Alt+Up and Alt+Down move one row one slot: the focused row in the table, the selected task in the chart. Stepping over a group header re-homes the row into that lane, phase and bar colour and dashed rule together, as one undo, exactly as a drag does. Under a column sort the move is bounded to the run of rows the sort considers TIED with the row, so an owner-sorted Adonis task moves within the Adonis block and nowhere else: the visible sort stays truthful and what changes is the tie-break, which is plan position, which persists. Proven first, against that scenario: 15 contiguous Adonis rows, a middle one swaps with its neighbour and nothing else moves, both ends of the run are no-ops that say so, a unique sort key does not move, and clearing the sort shows the two rows swapped in the plan with every other row where it was. Undo is per press. The changelog coalesces a burst into one line (600ms), and flushSave flushes a pending one, so the log and the sidecar still cannot disagree. Shift+N opens the New panel, gated on the app being idle: no editor, panel, menu, picker or drag, and nothing focused anywhere that a person could be typing into. Probed against all six cell editor types, the panel's own name field, the search box, and a mid-drag row. Tab commits the open cell and opens the next one ready to type, Shift+Tab back. It walks the rendered cells, so it follows the visible order under a sort, skips read-only cells and the delete button, and wraps around both ends. The 52-key fingerprint is unchanged from the 0.15.1 baseline apart from the version string.
6 files changed +329−19
README.md+9−2
@@ -4,7 +4,7 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau  ## Features -**Two lenses over one plan.** A **Gantt / Table** toggle in the header. The chart answers "when". The table answers "whose, and by when": every task and every milestone as a row with Owner, Start, Deadline, Status and Notes, sortable on any column, filtered to one person with the owner dropdown, and editable in place. An edit made in the table is already true on the chart when you toggle back, because there is only one set of items underneath. The choice of lens persists, and `project.view.mode` can pin it for a plan that is really an assignment list.+**Two lenses over one plan.** A **Gantt / Table** toggle in the header. The chart answers "when". The table answers "whose, and by when": every task and every milestone as a row with Owner, Start, Deadline, Status and Notes, sortable on any column, filtered to one person with the owner dropdown, and editable in place. An edit made in the table is already true on the chart when you toggle back, because there is only one set of items underneath. The choice of lens persists, and `project.view.mode` can pin it for a plan that is an assignment list more than a timeline.  **Add rows.** The New button creates a task or a milestone: name, lane, owner, dates, status, notes and provenance. Created rows live in the sidecar, so the data file is never rewritten, and they appear in every export. @@ -124,10 +124,17 @@ Omit `color` on a phase and it follows the Adom brand ramp, which adapts between | Key | Action | |-----|--------| | Ctrl+Z | Undo |-| Esc | Clear selection, close editor |+| Shift+N | Open the New panel. Fires only when nothing else is going on, so a capital N typed into a field stays a capital N |+| Alt+Up / Alt+Down | Move a row one slot: the focused row in the table, the selected task in the chart. Crossing a lane boundary moves the row into that lane. Under a column sort the row stays inside the run of rows the sort considers tied with it |+| Tab / Shift+Tab | In the table, commit the open cell editor and open the next one, ready to type. Wraps across rows and around the ends, and follows the visible order under a sort |+| Esc | Cancel the open field, then clear the row focus, then clear the selection | | Ctrl+click | Toggle item in selection | | Shift+click | Range select | +The **focused row** is the row whose cell editor you touched last, marked with+an accent bar down its left edge. It is not a separate thing to click: the edit you+were already making is what tells Alt+Up and Alt+Down which row you mean.+ ## Interactions  | Gesture | Action |
SKILL.md+4−1
@@ -195,6 +195,9 @@ rather than a category. - **Click a bar** to open the date editor with start/end pickers and dependency management - **Drag bar edges** to resize (changes start or end date) - **Ctrl+Z** to undo (100-level stack)+- **Shift+N** opens the New panel, gated on the app being idle: no editor, panel, menu or drag in progress, and nothing focused that a person could be typing into+- **Alt+Up / Alt+Down** moves one row one slot. The table moves its focused row (the row whose cell editor was touched last, marked with an accent bar on its left edge); the chart moves its selected task. In plan order the movement is free, and stepping over a group header re-homes the row into that lane, phase, bar colour and dashed rule together, exactly as a drag does. Under a column sort the movement is bounded to the contiguous run of rows the sort considers TIED with this one, so an owner-sorted Adonis task moves within the Adonis block and nowhere else: the visible sort stays truthful and what changes is the tie-break, which is plan position, which persists. A row alone in its sort key does not move and says so. Undo is per press; the changelog coalesces a burst into one line+- **Tab / Shift+Tab** inside a table cell editor commits the field and opens the next editable cell, with the same ready-to-type conventions as a click (a select opens its list, short text is selected, notes get the caret at the end). It skips read-only cells and the delete button, follows the visible order under a sort, and wraps across rows and around both ends of the table - **Auto-save** to `<datafile>-state.json` sidecar (800ms debounce, flushed immediately if the page is closed or hidden, so the changelog and the sidecar cannot disagree) - **Auto-load** saved state on page refresh - **Dynamic date range** computed from the data every render@@ -259,7 +262,7 @@ New changelog actions: `owner`, `status`, `desc` and `milestone.rename`. Milesto versions carry `ms: true` and a `key` instead of an `id`, since milestones have no id.  `project.view.mode` pins which lens a plan opens in, the same way `grain` pins the-cell size. Use it when the plan is really an assignment list.+cell size. Use it when the plan is an assignment list more than a timeline.  ## Read the changelog before you edit a plan 
docs/BUILD-NOTES.md+94
@@ -3,6 +3,100 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-10 (0.17.0, the keyboard round)++**The focused row costs no new click target.** It is the row whose cell editor you+touched last, marked with a 3px accent bar inset into the first cell. Giving the+table a separate "select the row" gesture would have put a second meaning on the+click that already opens an editor, and the one-click editors are the thing this+table is good at. So the edit you were already making is what says which row you+mean. The indicator is an inset box-shadow rather than a border or a pseudo-element+with a width, so it cannot move the row it marks: measured, the focused cell's left+edge and width are identical to an unfocused one's.++**Alt+Up and Alt+Down, not the bare arrows.** The arrows on their own belong to+whatever holds the keyboard, and to the scroller when nothing does. Alt is free+here in both lenses and in the browser chrome. The table moves its focused row, the+chart moves its selected task, and a chart selection of more than one row is a+different gesture (a batch move) that this deliberately is not.++**Crossing a lane boundary moves the lane, because that is the only rule this app+has about where a row lives.** The group header above a row IS its lane everywhere+else, and a keyboard move is not allowed to be the one gesture that breaks it.+Stepping down past a header lands at the top of the next lane, stepping up past one+lands at the bottom of the previous lane, and the phase, the bar colour and the+dashed rule go along, as one undo entry, exactly as a drag does. In the table this+looks like a row that changed its Phase cell without changing position, which is+correct: the header it stepped over has no table row of its own.++**Under a sort, movement is bounded to the tied run, and this was proven before+anything else was built.** Rows on screen under a column sort are not rows in the+plan, which is why dragging is refused there at all. A keyboard move can be allowed+because it can be bounded: the row may only swap with a neighbour the sort considers+EQUAL to it, so the visible order stays exactly what the sort claims. What actually+changes is the tie-break, which is plan position, which persists. The run is+computed with the same value function the sort used, extracted out of `tableRows`+for the purpose rather than reimplemented, because a second copy of a comparator is+how 0.8.3's export bug happened.++Measured against the exact scenario, on a copy of the live DC plan: owner sort+ascending, 15 Adonis rows in one contiguous run containing every Adonis row in the+plan; Alt+Down on the middle one leaves the owner column identical top to bottom,+swaps exactly those two rows, moves nothing else, and carries the row focus with it;+Alt+Down at the bottom of the run and Alt+Up at the top are no-ops that say "the end+of the tied group"; under a name sort, where every key is unique, every run is one+row and the press says "no other row is tied with this one under this sort". Then+clearing the sort: the plan holds the same set of rows, the two swapped their+relative order, the moved row now sits immediately after its partner, and removing+it from both lists leaves them identical, so nothing else moved. It is in the+sidecar's order.++**Undo per press, changelog per burst.** A held arrow is one intent and eight lines+of "reordered rows" is not a log anyone reads, so the changelog write is debounced+600ms and the diff is cumulative, which makes a burst one line naming the row.+Undo stays per press, because a keystroke is the unit you want back. Five presses+moved a row five slots, produced exactly one `key-move` changelog line, and took+five Ctrl+Z to put back. `flushSave` now flushes the pending key-move too: 0.8.2+bought the guarantee that the log and the sidecar never disagree, and a 600ms window+where a closed tab could drop the line would have sold it back.++**Shift+N is gated on the app being idle, which means more than "no editor open".**+It refuses while any cell editor, label editor, panel, menu, date picker or drag is+live, and while `document.activeElement` is any input, textarea, select or+contenteditable, anywhere, including surfaces this round did not write. Probed+against all six cell editor types, the create panel's own name field, the table+search box, and a mid-drag row with the drop indicator showing: the panel opened in+neither lens in any of them, no editor was disturbed, and the typed text survived.+Idle in both lenses, it opens. The New button's tooltip now names the key.++**Tab walks the rendered cells, not a hardcoded field list.** Committing first and+then reading the fresh DOM is what makes it follow the visible order under a sort+for free, skip the milestone start cell and the delete button (neither carries+`editable`), and stay correct if a commit re-sorts the row out from under itself.+`startCellEdit` already owns the ready-to-type conventions, so tabbing into a select+opens its list and tabbing into notes puts the caret at the end without a second+implementation. `preventDefault` runs inside native selects and date inputs as well,+or the browser's own focus walk carries on from the control being thrown away and+lands in the chrome. Wrapping goes around the ends of the table rather than+dead-ending. Verified: all eight editable fields of a row in DOM order then on to+the next row's name, the reverse from the last field, commits sticking at each stop+(an owner and a note typed mid-walk are both in the row afterwards), both wraps, and+a sorted walk landing on the next VISIBLE row.++**Nothing rendered moved.** The 52-key fingerprint from 0.16.0 is identical to the+0.15.1 baseline except the version string, and the behavioural sweep re-checked the+0.15.0 and earlier work alongside the new keys: row drag with its indicator and its+undo, collapse and expand with zero cap overlaps, the eight column grips, delete+with undo restoring the row and its name, and header widths unchanged while an+80-character value is typed into a cell. Zero JS errors throughout.++**One measurement note for whoever comes next.** Headless Chrome under+`--virtual-time-budget` reports table column widths as the pre-layout equal split on+alternating measurements, on every build including 0.15.1. It is the harness, not+the app: the settled widths reproduce the recorded fingerprint exactly. Measure the+during-edit widths against the before-edit widths taken in the same layout pass, or+warm the table up with one open-and-cancel first.+ ## Build notes, 2026-08-10 (0.16.0, the init() refactor)  **The rule this file kept restating is now enforced by the shape of the code.**
gantt.html+219−13
@@ -431,6 +431,11 @@ body.row-dragging .data-table tbody tr:hover { background: none; } .row-drop::before { content: ''; position: absolute; left: 0; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); } /* The lane a drop would land in, called out while dragging across it. */ .data-table tbody tr.lane-target td.c-phase { box-shadow: inset 2px 0 0 var(--accent); }+/* The focused row: the row whose editor you touched last, and the row Alt+Up and+   Alt+Down move. An inset shadow rather than a border or a pseudo-element with a+   width, so the indicator cannot shift a single pixel of the row it marks. */+.data-table tbody tr.row-focus > td:first-child { box-shadow: inset 3px 0 0 var(--accent); }+.data-table tbody tr.row-focus > td { background: rgba(0,184,177,0.045); } /* Plan order is the only order you can drag in, so say so rather than letting    the row silently refuse. */ .data-table tbody tr.no-drag { cursor: default; }@@ -991,7 +996,7 @@ $('hdr').innerHTML = `     <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>-    <button class="new-btn" id="btnNew" title="Add a task or a milestone to this plan">+    <button class="new-btn" id="btnNew" title="New task &middot; Shift+N">       <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>New</button>     <div class="vsep"></div>     <div class="fs-ctl" id="fsCtl">@@ -1141,6 +1146,116 @@ function moveRowToPhase(r,np){   if(r._msLine)r._msLine.style.color=phaseColor(np);   applyFilter();refreshChartGeometry(); }++// ── Moving a row with the keyboard ──+//+// Alt+Up and Alt+Down move one row one slot: the focused row in the table, the+// selected task in the chart. Alt, because the arrows on their own belong to+// whatever the user is typing into, and because nothing else in the app or the+// browser chrome claims that chord here.++// The phase a row's position now implies. The group header above a row IS its+// lane, everywhere else in this app, and a keyboard move is not allowed to be+// the one gesture that breaks that: a row that ends up under another header has+// moved lanes, and its phase, its bar colour and its dashed rule go with it.+function phaseAtPosition(r){+  const i=items.indexOf(r);+  for(let k=i-1;k>=0;k--) if(items[k].type==='group') return items[k].phase;+  return r.phase;+}+function moveItemAdjacent(r,target,after){+  const ri=items.indexOf(r); if(ri<0)return false;+  items.splice(ri,1);+  const ti=items.indexOf(target);+  if(ti<0){ items.splice(ri,0,r); return false; }   // put it back rather than lose it+  items.splice(after?ti+1:ti,0,r);+  return true;+}+// Plan order: free movement. Group headers are not slots of their own, so the row+// steps over them, and stepping over one means it has changed lane.+function planNeighbourMove(r,dir){+  const idx=items.indexOf(r); if(idx<0)return false;+  let j=idx+dir, crossed=false;+  while(j>=0&&j<items.length&&items[j].type==='group'){ crossed=true; j+=dir; }+  if(j<0||j>=items.length)return false;             // the top or the bottom of the plan+  // Going down past a header lands at the top of the next lane; going up past one+  // lands at the bottom of the previous lane. Otherwise it is a plain swap.+  return moveItemAdjacent(r,items[j],(dir>0)!==crossed);+}+// Under a column sort the rows on screen are not the rows in the plan, so a move+// that could go anywhere would either lie about the sort or silently re-sort the+// plan. Movement is bounded to the run of rows the sort considers TIED with this+// one: under an owner sort an Adonis task moves within the Adonis block and+// nowhere else, the visible order stays exactly what the sort says it is, and+// what actually changes is the tie-break, which is plan position, which persists.+function sortedNeighbourMove(r,dir){+  const rows=tableRows();+  const vi=rows.indexOf(r);+  if(vi<0)return 'gone';+  const key=sortValueOf(r,sortKey);+  let a=vi,b=vi;+  while(a>0&&sortValueOf(rows[a-1],sortKey)===key)a--;+  while(b<rows.length-1&&sortValueOf(rows[b+1],sortKey)===key)b++;+  if(a===b)return 'alone';+  const ti=vi+dir;+  if(ti<a||ti>b)return 'edge';+  // The comparator breaks ties on plan position ascending, in both sort+  // directions, so landing after the neighbour is what puts the row after it.+  return moveItemAdjacent(r,rows[ti],dir>0)?'ok':'gone';+}+// The chart has one selection model and it is the one this uses. More than one+// row selected is a different gesture (a batch move) and is not this one.+function soleSelectedRow(){+  if(selected.size!==1)return null;+  const id=[...selected][0];+  return items.find(r=>r.id===id)||null;+}+// A held arrow key is one intent, not eight. Undo stays per press, because a+// keystroke is the unit you want back, but the changelog waits for the burst to+// finish and then records the net move as one readable line.+let keyMoveTimer=null;+function scheduleKeyMoveSave(){+  clearTimeout(keyMoveTimer);+  keyMoveTimer=setTimeout(()=>{keyMoveTimer=null;scheduleSave('key-move');},600);+}+function flushKeyMove(){+  if(!keyMoveTimer)return;+  clearTimeout(keyMoveTimer);keyMoveTimer=null;+  scheduleSave('key-move');+}+function moveFocusedRow(dir){+  const table=prefs.view==='table';+  const r=table?rowItem(focusedKey):soleSelectedRow();+  if(!r){+    flash(saveInd, table+      ? 'click a cell first, then Alt+Up and Alt+Down move that row'+      : (selected.size>1?'select one task to move it':'select a task first'));+    return;+  }+  const sorted=table&&!manualOrder();+  const fromPhase=r.phase;+  const order=items.map(rowOrderKey);+  const res=sorted?sortedNeighbourMove(r,dir):(planNeighbourMove(r,dir)?'ok':'edge');+  if(res!=='ok'){+    if(res==='alone')flash(saveInd,'no other row is tied with this one under this sort');+    else if(res==='edge')flash(saveInd,sorted?'the end of the tied group':'the end of the plan');+    return;+  }+  undoStack.push({type:'phase',r,phase:fromPhase,order});+  if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);+  const np=phaseAtPosition(r);+  if(np!==undefined&&np!==r.phase){+    r.phase=np;+    if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(np);+    if(r._msLine)r._msLine.style.color=phaseColor(np);+  }+  reflowRows();applyFilter();refreshChartGeometry();renderTable();+  // A milestone's row key is its position among the milestones, so it can change+  // under the row that carries it. Re-read it rather than keep a stale one.+  if(table)setFocusedRow(rowKey(r));+  moveHint='"'+(r.name||'a row')+'"';+  scheduleKeyMoveSave();+} function undo(){   if(!undoStack.length)return;   const sn=undoStack.pop();@@ -1185,9 +1300,40 @@ function undo(){   if(prefs.view==='table')renderTable();   clearTimeout(undoTimer);undoToast.classList.add('show');undoTimer=setTimeout(()=>undoToast.classList.remove('show'),1800);scheduleSave('undo'); }+// Is anything at all in progress? A chord that opens a panel must never fire in+// the middle of something else, and "something else" includes any field anywhere,+// not just this app's own editors.+function isTypingTarget(el){+  if(!el)return false;+  if(el.isContentEditable)return true;+  const t=(el.tagName||'').toUpperCase();+  return t==='INPUT'||t==='TEXTAREA'||t==='SELECT';+}+function appIsIdle(){+  if(tblEditing||labelEditing||rowDrag||laneDrag||dragState||reorderState||gesture)return false;+  if(editorEl.classList.contains('show'))return false;+  if(datePop&&datePop.classList.contains('show'))return false;+  if(isTypingTarget(document.activeElement))return false;+  return true;+} function wireGlobalKeys(){ document.addEventListener('keydown',e=>{   if((e.ctrlKey||e.metaKey)&&e.key==='z'){e.preventDefault();undo();}+  // Alt+Up and Alt+Down move a row. Not the bare arrows: those belong to whatever+  // has the keyboard, and to scrolling when nothing does.+  if(e.altKey&&!e.ctrlKey&&!e.metaKey&&(e.key==='ArrowUp'||e.key==='ArrowDown')){+    if(isTypingTarget(document.activeElement))return;+    e.preventDefault();+    moveFocusedRow(e.key==='ArrowDown'?1:-1);+    return;+  }+  // Shift+N opens the create panel, and only when the app is genuinely idle: a+  // capital N typed into a task name is a capital N and nothing else.+  if(e.shiftKey&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&(e.key==='N'||e.key==='n')){+    if(!appIsIdle())return;+    e.preventDefault();closeMenus();openCreateEditor();+    return;+  }   if(e.key!=='Escape')return;   // Innermost first. A native select or datalist popup swallows its own Escape   // before this ever runs, which is exactly the first rung of the stack.@@ -1200,6 +1346,9 @@ document.addEventListener('keydown',e=>{   if(rowDrag||laneDrag){ endRowDrag(false); endLaneDrag(false); return; }   if(tblEditing){ tblEditing.cancel(); return; }   if(editorEl.classList.contains('show')){ closeEditor(); return; }+  // The row focus is the outermost thing the table owns, so it goes after the+  // field and after any panel, and before the chart's own selection.+  if(focusedKey){ setFocusedRow(null); return; }   clearSelection(); closeMenus(); }); }@@ -1436,6 +1585,10 @@ function scheduleSave(via){diffAndLog(via||'edit');clearTimeout(saveTimer);saveT // put away. sendBeacon is the only send guaranteed to outlive the document; // keepalive fetch is the fallback. function flushSave(){+  // A burst of Alt+Arrow presses holds its changelog line back for 600ms. If the+  // tab is put away inside that window the line still has to go, or the log and+  // the sidecar would disagree about a move that definitely happened.+  flushKeyMove();   if(!saveTimer)return;   clearTimeout(saveTimer);saveTimer=null;   if(SNAPSHOT)return;@@ -2443,7 +2596,10 @@ $('btnHelp').addEventListener('click',e=>{     <div class="dd-sep"></div>     <div class="dd-label">keyboard</div>     <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+Z</span>Undo</div>-    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Esc</span>Clear selection</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>+    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Tab</span>Commit and open the next field. Shift+Tab goes back</div>+    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Esc</span>Cancel the field, then the row, then the selection</div>     <div class="dd-sep"></div>     <div class="dd-label">legend</div>     <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click phase</span>Toggle phase visibility</div>@@ -3001,6 +3157,19 @@ function syncOwnerControls(){   ownerListEl.innerHTML=owners.map(o=>`<option value="${esc(o)}"></option>`).join(''); } +// The value a column sorts on. It lives out here rather than inside tableRows+// because a keyboard move has to ask "which rows does the sort consider tied with+// this one", and the only safe answer is the one the sort itself used.+function sortValueOf(r,key){+  if(key==='name')return (r.name||'').toLowerCase();+  if(key==='phase')return (phaseNameOf(r)||'').toLowerCase();+  if(key==='owner')return ownerOf(r).toLowerCase();+  if(key==='start')return isMs(r)?'':(r.start||'');+  if(key==='status'){const k=statusOf(r);const i=STATUS_ORDER.indexOf(k);return i<0?(k?'z'+k:''):String(i);}+  if(key==='notes')return (r.description||'').toLowerCase();+  if(key==='prov')return provLabel(r).toLowerCase();+  return deadlineOf(r);+} function tableRows(){   const q=(searchInp.value||'').trim().toLowerCase();   const own=ownerSel.value, st=statusSel.value;@@ -3019,16 +3188,7 @@ function tableRows(){     rows.push(r);   });   if(manualOrder())return rows;      // already in plan order-  const val=r=>{-    if(sortKey==='name')return (r.name||'').toLowerCase();-    if(sortKey==='phase')return (phaseNameOf(r)||'').toLowerCase();-    if(sortKey==='owner')return ownerOf(r).toLowerCase();-    if(sortKey==='start')return isMs(r)?'':(r.start||'');-    if(sortKey==='status'){const k=statusOf(r);const i=STATUS_ORDER.indexOf(k);return i<0?(k?'z'+k:''):String(i);}-    if(sortKey==='notes')return (r.description||'').toLowerCase();-    if(sortKey==='prov')return provLabel(r).toLowerCase();-    return deadlineOf(r);-  };+  const val=r=>sortValueOf(r,sortKey);   // A blank sorts last in both directions: an unassigned row should never push   // the thing you are looking for off the top of the list.   rows.sort((a,b)=>{@@ -3093,6 +3253,7 @@ function renderTable(){       +`<td class="c-del">${editable?`<button class="row-del" type="button" title="Delete this row from the plan">&times;</button>`:''}</td>`       +'</tr>';   }).join('');+  markFocusedRow(); }  // ── Drag a lane ──@@ -3417,10 +3578,45 @@ tableBody.addEventListener('click',e=>{ }  // ── Inline cell editing ──+//+// The focused row is the row whose editor you touched last. It is not a+// second click target competing with the one-click cell editors: nothing new to+// aim at, no extra gesture to learn, and the thing you were already doing tells+// the app which row you mean. Esc gives it back, innermost first: the field, then+// the row.+let focusedKey=null;+function markFocusedRow(){+  if(!tableBody)return;+  tableBody.querySelectorAll('tr.row-focus').forEach(t=>t.classList.remove('row-focus'));+  if(!focusedKey)return;+  const tr=[...tableBody.querySelectorAll('tr[data-key]')].find(t=>t.dataset.key===focusedKey);+  if(tr)tr.classList.add('row-focus');+}+function setFocusedRow(k){ focusedKey=k||null; markFocusedRow(); }+// Tab commits the field and opens the next one to the right, ready to type. The+// walk is over the rendered cells, so it follows the visible order under a sort,+// skips the milestone start cell and the delete button because neither carries+// `editable`, and wraps around the ends rather than dead-ending.+function tabToCell(key,field,dir){+  const rows=[...tableBody.querySelectorAll('tr[data-key]')];+  if(!rows.length)return;+  const ri=rows.findIndex(t=>t.dataset.key===key);+  if(ri<0)return;                                   // the edit filtered its own row away+  const cells=[...rows[ri].querySelectorAll('td.editable[data-field]')];+  const ci=cells.findIndex(c=>c.dataset.field===field);+  if(ci>=0&&ci+dir>=0&&ci+dir<cells.length){ startCellEdit(cells[ci+dir]); return; }+  // Past the end of the row: the next row that has anything editable in it.+  for(let n=1;n<=rows.length;n++){+    const nr=rows[(ri+dir*n+rows.length*n)%rows.length];+    const nc=[...nr.querySelectorAll('td.editable[data-field]')];+    if(nc.length){ startCellEdit(dir>0?nc[0]:nc[nc.length-1]); return; }+  }+} let tblEditing=null; function startCellEdit(td){   if(tblEditing)return;   const tr=td.closest('tr'); if(!tr)return;+  setFocusedRow(tr.dataset.key);   const r=rowItem(tr.dataset.key), field=td.dataset.field;   if(!r||!field)return;   let el;@@ -3493,7 +3689,16 @@ function startCellEdit(td){   // this cell", not "clear the chart selection", so the app's keys stop here.   el.addEventListener('keydown',e=>{e.stopPropagation();     if(e.key==='Enter'){e.preventDefault();finish(true);}-    else if(e.key==='Escape'){e.preventDefault();finish(false);}});+    else if(e.key==='Escape'){e.preventDefault();finish(false);}+    else if(e.key==='Tab'){+      // preventDefault here even inside a native select or a date input, or the+      // browser's own focus walk carries on from the control we are about to+      // throw away and lands somewhere in the chrome.+      e.preventDefault();+      const fromKey=tr.dataset.key, fromField=field, back=e.shiftKey;+      finish(true);+      tabToCell(fromKey,fromField,back?-1:1);+    }});   el.addEventListener('blur',()=>finish(true));   if(el.tagName==='SELECT')el.addEventListener('change',()=>finish(true)); }@@ -3581,6 +3786,7 @@ tableBody.addEventListener('click',e=>{ // a speed bump, not a safety net. function deleteRow(r){   const label=r.name||r.label||'row';+  if(focusedKey===rowKey(r))focusedKey=null;   const info=removeRow(r);   if(!info)return;   undoStack.push({type:'delete',r,idx:info.idx,droppedDeps:info.droppedDeps});
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.16.0",+  "version": "0.17.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.16.0",+  "version": "0.17.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+}