← Commit history

0.22.2: one Tab per column, and three long-lead tasks

Drew Owens ·a09ef1224a · ·parent 055a28d
PART A. The native select popup was the whole bug and could not be worked around. Landing on a status or phase cell auto-expanded the browser's dropdown, which people like, but leaving it cost two keypresses: while that popup is open the page receives no key events at all, because it is browser chrome rather than DOM. The first Tab went to the browser to close it and only the second reached the app. There is no event to intercept and no way to ask whether the popup is open. So the popup is gone and the choices are ours: a small list rendered next to the cell, fixed-positioned on the body because cells clip their own overflow under the fixed table layout. The select itself is still there, still focused, still doing all its own keyboard work, so arrows change the value, type-ahead works, and Enter, Tab and Esc all reach the app. Landing still shows the choices; leaving now costs exactly one Tab. Measured: eight stops for eight columns forward, the same eight in reverse, both selects showing five options with the current value marked. Tab is claimed in the capture phase now, which is what stops a date input walking its own day, month and year segments instead of moving on. Arrow keys are left alone, so segments and values still work natively and a focused date editor never moves the row selection. Honest limit: showPicker needs a real user gesture and a synthetic click is not one, so headless could never reproduce the two-Tab symptom. The fix is verified structurally: the list is ours, the walk is one stop per column, and no native select popup is opened at all any more. PART B. Three tasks from the team's long-lead DMs, exactly the three asked for. Noah's drone PCB order (not-started on purpose: the DM records Drew's instruction to pay for expediting, not that it was placed) and two Adonis rows carrying a message-precise link to his 2026-08-10 16:56 list, pulled with adom-google. The mechanics are the interesting part. An id-less row's key is '#row' plus its INDEX in the data file, so inserting mid-lane would renumber every group header and milestone below it and re-point every sidecar override keyed on them. The rows are APPENDED instead, and placed in the plan through the sidecar order array, inserted immediately before the next group header, which is where homeRow() puts a new row in that lane. The transform asserts the pre-existing order is unchanged once the three new keys are removed, and that no other sidecar key moved. stateRev is bumped, so a tab that loaded earlier gets a 409 and is told to reload rather than writing a copy without these rows back over them. 113 to 116 items, nothing lost, order preserved, all three render in both lenses with live provenance links. Three changelog lines, actor Drew Owens.
5 files changed +146−16
SKILL.md+1−1
@@ -208,7 +208,7 @@ rather than a category. - **Enter** on the selected row opens its NAME editor, ready to type with the text selected. Inside a cell editor Enter commits and steps down one row, the spreadsheet convention, so a column of values is Enter, type, Enter, type; it does not open the next editor, and a second Enter does - **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** walks the selected row's fields. With no editor open it opens the first editable cell (Shift+Tab the last); inside an editor it 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+- **Tab / Shift+Tab** walks the selected row's fields at one keypress per column, including the select and date columns. A select cell shows its choices in the app's own list rather than the browser's popup, because that popup swallows the first Tab to close itself; the select keeps all its native keyboard behaviour. Date cells keep their native arrow keys for segments and values, and Tab leaves the field instead of walking its segments. With no editor open it opens the first editable cell (Shift+Tab the last); inside an editor it 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
docs/BUILD-NOTES.md+60
@@ -3,6 +3,66 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-11 (0.22.2, one Tab per column, and three long-lead tasks)++**The native select popup was the whole bug, and it could not be worked around.**+Landing on a status or phase cell auto-expanded the browser's dropdown, which people+like, but leaving it cost two keypresses: while that popup is open the page receives+NO key events at all, because it is browser chrome rather than DOM. The first Tab+went to the browser to close it and only the second reached the app. There is no+event to intercept and no way to ask whether the popup is open, so no amount of+handler work on the select could have fixed it.++So the popup is gone and the choices are ours now: a small list rendered next to the+cell, fixed-positioned on the body because the cells clip their own overflow under+the fixed table layout. The `<select>` itself is still there, still focused, and+still doing every bit of its own keyboard work, so arrows change the value, type-ahead+works, and Enter, Tab and Esc all reach the app. Landing on a dropdown still shows the+choices; leaving one now costs exactly one Tab, like every other field. Measured: a+full row is eight stops for eight columns, both selects showing their five options+with the current value marked, and the same eight in reverse for Shift+Tab.++**Tab is claimed in the capture phase now.** That is what keeps a date input from+walking its own day, month and year segments instead of moving to the next column: the+key is taken before the input's own default action can run. Arrow keys are deliberately+left alone, so left and right still move between segments and up and down still change+the value, and a focused date editor never moves the row selection. Which segment a+date input starts on is the browser's own locale rendering and there is no API to+choose it; it lands on the first segment, which in a day-first locale is the day.++**Honest limit on the verification.** `showPicker()` needs a real user gesture, and a+synthetic click is not one, so headless Chrome could never reproduce the two-Tab+symptom in the first place. The fix is verified structurally instead: our list exists+and is ours, the walk is one stop per column, and no native popup is opened for a+select at all any more, which is the property that made the bug possible.++**Three tasks added from the team's long-lead DMs**, exactly the three asked for and+no others. Noah's drone PCB order carries the DM link given; the two Adonis rows carry+the exact message pulled with `adom-google chat read` from `spaces/nJC5fMAAAAE`, his+2026-08-10 16:56 list, built into a message-precise link the same way the reflow+provenance was in 0.9.0. The drone PCB order is `not-started` on purpose: the DM+records Drew's instruction to pay for expedited processing, not that the order has+been placed.++**The mechanics are the interesting part.** Mutations need a session, so these went in+by direct file edit, and a direct file edit has one trap this app built its own guard+against. An id-less row's key is `#row` plus its INDEX in the data file, so inserting+rows in the middle of the Hardware lane would renumber every group header and milestone+below it and silently re-point every sidecar override keyed on them. The three rows are+therefore APPENDED to the data file, where they change no existing index, and placed in+the plan through the sidecar's order array instead: inserted immediately before the+next group header, which is exactly where `homeRow()` puts a new row in that lane. The+transform asserts that the pre-existing order is unchanged when the three new keys are+removed from it, and that no other sidecar key moved. `stateRev` is bumped, so a tab+that loaded before this gets a 409 on its next save and is told to reload rather than+writing a copy without these rows back over them, which is the 0.21.0 lesson applied+before it could bite again.++Merged plan 113 to 116, exactly three added, nothing lost, every pre-existing row in+the same relative order, and all three render in both lenses under the Hardware header+with their provenance links live. Three changelog lines, actor Drew Owens because he+asked for them, with the via naming where they came from.+ ## Build notes, 2026-08-11 (0.22.1, Tab stopped escaping into the table)  Reported with a screenshot: a row selected, no editor open, press Tab, and the focus
gantt.html+82−12
@@ -503,6 +503,18 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .col-grip:hover::after, .col-grip.dragging::after { background: var(--accent); } body.col-sizing { cursor: col-resize; user-select: none; } +/* The choice list for a select cell. It is OUR dom, not the browser's popup, which+   is the whole point: a native popup is browser chrome and it swallows the first+   Tab to close itself, so leaving a dropdown cost a keypress that leaving a text+   field did not. Fixed position and appended to the body, because the cells clip+   their own overflow under the fixed table layout. */+.cell-list { position: fixed; z-index: 60; min-width: 130px; max-height: 232px; overflow-y: auto;+  background: var(--surface); border: 1px solid var(--border); border-radius: 7px; padding: 4px;+  box-shadow: 0 8px 22px rgba(0,0,0,0.34); font-family: 'Satoshi', sans-serif; font-size: 12px; }+.cell-list .cl-opt { padding: 5px 9px; border-radius: 5px; color: var(--text-body); cursor: pointer; white-space: nowrap; }+.cell-list .cl-opt:hover { background: var(--surface2); color: var(--text); }+.cell-list .cl-opt.on { background: rgba(0,184,177,0.14); color: var(--accent); font-weight: 600; }+ /* The Adom identity card, compressed rendering: avatar plus real name. Fixed    maximum width so the chip can never move anything else in the header as a name    changes length. Monochrome, no emoji, one teal accent, per the identity-card@@ -4144,6 +4156,54 @@ function tabToCell(key,field,dir){   } } let tblEditing=null;+// ── The choice list for select cells ──+//+// A native <select> keeps every bit of its keyboard behaviour: arrows change the+// value, type-ahead works, Enter and Tab and Esc all reach our own handler. What it+// does NOT do any more is open the browser's popup, because that popup is browser+// UI: while it is open the page gets no key events at all, so the first Tab was+// spent closing it and the second one moved a column. This list mirrors the select+// instead, so landing on a dropdown still shows the choices and leaving it still+// costs exactly one Tab.+let cellList=null;+function closeCellList(){+  if(!cellList)return;+  cellList.el.remove();+  window.removeEventListener('scroll',closeCellList,true);+  window.removeEventListener('resize',closeCellList);+  cellList=null;+}+function syncCellList(){+  if(!cellList)return;+  const v=cellList.sel.value;+  cellList.el.querySelectorAll('.cl-opt').forEach(o=>{+    const on=o.dataset.value===v;+    o.classList.toggle('on',on);+    if(on&&o.scrollIntoView)o.scrollIntoView({block:'nearest'});+  });+}+function openCellList(sel,td){+  closeCellList();+  const el=document.createElement('div');+  el.className='cell-list';+  el.innerHTML=[...sel.options].map(o=>+    `<div class="cl-opt" data-value="${esc(o.value)}">${esc(o.textContent)}</div>`).join('');+  document.body.appendChild(el);+  const r=td.getBoundingClientRect();+  el.style.left=Math.max(4,Math.min(r.left,window.innerWidth-el.offsetWidth-6))+'px';+  const below=window.innerHeight-r.bottom;+  el.style.top=(below>el.offsetHeight+8?r.bottom+2:Math.max(4,r.top-el.offsetHeight-2))+'px';+  el.addEventListener('mousedown',e=>e.preventDefault());   // never steal focus from the select+  el.addEventListener('click',e=>{+    const o=e.target.closest('.cl-opt'); if(!o)return;+    sel.value=o.dataset.value;+    sel.dispatchEvent(new Event('change',{bubbles:true}));+  });+  cellList={el,sel};+  window.addEventListener('scroll',closeCellList,true);+  window.addEventListener('resize',closeCellList);+  syncCellList();+} function startCellEdit(td){   if(tblEditing)return;   const tr=td.closest('tr'); if(!tr)return;@@ -4188,9 +4248,15 @@ function startCellEdit(td){   // synchronously inside the click, which is the only place showPicker() is   // allowed to open anything.   el.focus();-  if(el.tagName==='SELECT'||el.hasAttribute('list')){-    // A select or a datalist is a list of choices: the click that opens it should-    // show the choices. Feature-detected and swallowed, so a browser without+  if(el.tagName==='SELECT'){+    // Our own list rather than the browser's popup. Same benefit on arrival, and+    // leaving it costs one Tab like every other field.+    openCellList(el,td);+    el.addEventListener('change',syncCellList);+  } else if(el.hasAttribute('list')){+    // The owner datalist is a suggestion list, not a fixed set of choices, and its+    // popup does not intercept Tab the way a select popup does, so it keeps the+    // native affordance. Feature-detected and swallowed, so a browser without     // showPicker just gets a focused control instead of a console error.     if(typeof el.showPicker==='function'){ try{ el.showPicker(); }catch(e){} }   }@@ -4209,6 +4275,7 @@ function startCellEdit(td){   let done=false;   const finish=save=>{     if(done)return;done=true;tblEditing=null;+    closeCellList();     // Blur before the re-render so focus lands on the document rather than being     // orphaned on a node that is about to be thrown away.     try{ el.blur(); }catch(e){}@@ -4218,6 +4285,17 @@ function startCellEdit(td){   tblEditing={td,el,r,field,cancel:()=>finish(false)};   // Ctrl+Z inside a field is the browser's own text undo, and Esc means "cancel   // this cell", not "clear the chart selection", so the app's keys stop here.+  // Capture phase: Tab is claimed before anything else can act on it, which is what+  // stops a date input walking its own day, month and year segments instead of+  // moving to the next column. Arrow keys are deliberately NOT touched, so left and+  // right still move between segments and up and down still change the value.+  el.addEventListener('keydown',e=>{+    if(e.key!=='Tab')return;+    e.preventDefault();e.stopPropagation();+    const fromKey=tr.dataset.key, fromField=field, back=e.shiftKey;+    finish(true);+    tabToCell(fromKey,fromField,back?-1:1);+  },true);   el.addEventListener('keydown',e=>{e.stopPropagation();     if(e.key==='Enter'){       // Commit and step down a row, the spreadsheet convention, so a column of@@ -4231,15 +4309,7 @@ function startCellEdit(td){       if(i>=0)focusRowAt(i+1);       // clamped, so the last row commits and stays     }     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)); }
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.22.1",+  "version": "0.22.2",   "description": "Interactive Gantt chart viewer for project timelines, with a table view over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",   "main": "server.js",   "bin": {
page.json+2−2
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.22.1",+  "version": "0.22.2",   "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+}