← Commit history

0.55.0: Escape leaves a field, and the table's Clear button goes

Drew Owens ·b7f3f89e1e · ·parent e40aee3
6 files changed +222−10
README.md+3−1
@@ -42,6 +42,8 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc  **Status is editable from the chart.** Click a task's status pill in the label column to change it from a small on-brand dropdown (not started / in progress / blocked / complete / cancelled). It is the same edit as the table's status cell: one undo, logged to the changelog, reflected in both lenses. +**Esc gets you out of a field.** Every text box in the app hands the keyboard back when you press Escape: the table's filter box, the panel fields, the section editors, the type-in on the owner list, the date fields. The text you typed stays exactly where it is, so a filter you built is never wiped by a stray key, and the shortcuts are live again immediately rather than after a trip to the mouse. One Escape is one action: it never blurs a field AND closes something behind it in the same press.+ **Owner is editable from the chart too.** Right of the status pill, every task row carries an owner pill, showing who has it or a quiet "unassigned" when nobody does. Click it for a list of everyone already on the plan, plus a field for a name that is not on it yet; a new name is offered to every other row the moment it commits. Same path as the table's Owner cell, so it is one undo, one changelog event, and both lenses agree. The list scrolls when a plan has more people than it can show, by the wheel, the trackpad or the scrollbar; Escape closes it, so does a click anywhere outside it, and so does scrolling the plan out from under it.  **Rename a row in place.** Click the name text in the label column to edit it. Enter commits the new name, closes the field and leaves the row selected, so the rename hands straight over to the keyboard. Escape cancels.@@ -321,7 +323,7 @@ long editing run. | 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, and a lane that is collapsed OPENS the moment the moving row lands in it, so you can see what you are moving among. Under a column sort the row stays inside the run of rows the sort considers tied with it. The pane follows the row it is moving, keeping five rows visible ahead of it in the direction of travel, and both columns stay locked. With several rows selected the whole set moves as a group; if the selection has gaps in it, the FIRST press closes it up into one block at the topmost selected row and every press after that moves the block | | Tab / Shift+Tab | In the table, walk the selected row's fields. With no editor open it opens the first editable cell (Shift+Tab the last); with one open it commits and moves to the next. Wraps across rows and around the ends, and follows the visible order under a sort. With no row selected, Tab walks the page's own controls as usual. In the edit panels it is one keypress per field the same way, including the date fields, which Tab leaves whole instead of walking their day, month and year segments |-| Esc | Cancel the open field, then clear the row focus, then clear the selection |+| Esc | Leave the field first: pressed with the caret in any text box it takes the focus back out of it, so the keyboard shortcuts work again on the very next keypress. It does not clear the box, and it does not do anything else in the same press. Fields that already have an Esc of their own keep it (a name editor cancels, a dropdown's type-in closes the list, a panel field closes the panel). With nothing focused it goes on as before: cancel the open field, then clear the row focus, then clear the selection | | Ctrl+click | Toggle item in selection | | Shift+click | Range select | 
SKILL.md+5
@@ -303,6 +303,11 @@ data, plus "All owners" and "Unassigned"; it answers "what is mine". There is al status dropdown and a free-text filter over name, owner, phase, notes and status. The legend's phase and milestone toggles apply to the table as well, so the two lenses never disagree about what is in scope. The owner filter and the sort survive a reload.+Each filter is cleared where it is set: the two dropdowns open on All, and the text+filter is cleared by emptying the box. There is no Clear button; it was one control for+a job the other three were already doing, in the busiest strip in the app. **Escape in+the filter box blurs it and leaves the text alone**, so the shortcuts come back without+the filter going away.  **Editing.** Click a cell to edit Name, Phase, Owner, Start, Deadline, Status, Notes or Provenance in place, and the button at the end of a row deletes it (one click, undoable, with a toast). Moving a row to another phase re-homes it under that lane's group header on the chart. Deleted rows are recorded in the sidecar as `deleted: [key, ...]` and applied before the row order, so a deliberate removal is never mistaken for a lost key. Enter commits, Escape cancels, clicking away commits. Every edit mutates the item,
docs/BUILD-NOTES.md+134
@@ -3,6 +3,140 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-25 (0.55.0, Escape leaves a field, and the Clear button goes)++Two changes Drew asked for, one repair the first of them turned up on the way. `gantt.html`+only; `server.js` is untouched and byte-identical to 0.54.0.++### 1. Escape gets you out of a field++**What it was like.** Type a filter into the table's "Filter by text" box and the app's+keyboard was gone. The box stops its own keydown, which is right (a capital N typed into a+field must be a capital N, not the New panel), but nothing above it ever gave the focus+back, so the arrows, Delete, Shift+N, Shift+D and Ctrl+C all stayed dead until you reached+for the mouse and clicked somewhere else. Escape, the key everybody presses, did nothing+useful. Worse than nothing, in fact: `<input type="search">` clears itself on Escape in+Blink, so the one thing Escape DID do was silently wipe the filter and leave the caret+sitting in the now-empty box. Both halves measured on 0.54.0, side by side with 0.55.0, in+`g55/filterab.mjs`.++**What it is like now.** Escape pressed with the caret in a plain text field blurs it. The+text stays. The filter stays applied. The focus goes back to the document, which is the same+place closing a panel leaves it, so the next keypress is an app keypress: ArrowDown moves+the row focus straight after the Escape.++**One chokepoint, not twenty patches.** A capture-phase `keydown` on `document`, registered+in `wireGlobalKeys` right after the armed-delete withdrawal listener and before the main+handler. It reads `document.activeElement`, asks two questions, and either takes the key or+gets out of the way:++* `isEscBlurTarget(el)`: is this a text-like field? `TEXTAREA`, or an `INPUT` whose type is+  in `ESC_TEXT_TYPES` (text, search, number, tel, url, email, password, date, month, week,+  time, datetime-local). A `SELECT` is deliberately NOT one: its Escape belongs to whatever+  list is open over it, ours or the browser's, and the app-level handler behind that is+  unchanged. A date field IS one, and the blur is all that changes there, so the arrows and+  typing still walk its day, month and year segments.+* `el.closest(ESC_OWNED)`: does something more specific already own this key? One selector+  string, in one place, listing the seven containers that do. Nothing was patched per input;+  the per-input handlers are still the more specific layer and they still run.++If both answers say "mine", it calls `preventDefault()` (which is what stops a search field+clearing itself), `stopPropagation()`, and blurs. Stopping the event dead is the whole of+the no-double-fire guarantee: the app-level handler below never sees that keypress, so one+Escape cannot both blur the filter box AND exit dependency mode or close the panel behind+it. The NEXT Escape, with nothing focused, does exactly what it always did.++Three live states are let through untouched before any of that: `rowDrag`, `laneDrag`,+`dragState` and an active `reorderState` own Escape outright whatever holds the focus, and+their own capture listeners are registered later than this one.++**The inventory, and what each field does on Escape.** Every text surface in the app, in+four behaviour classes:++| Field | Class | On Escape |+|---|---|---|+| `#tblSearch`, the table's filter box | plain | blurs; text kept, filter kept |+| `.label-edit`, the chart's inline name editor | cancel | cancels the rename, removes the field |+| `.bar-note-edit`, the note typed onto a bar | cancel | cancels the note (see 3 below) |+| `.cell-input`, a table cell editor (name, owner, notes, provenance, and the date cells) | cancel | cancels the cell, blurs before the re-render |+| `.cl-new-inp`, the owner list's type-in row | close list | closes the list, assigns nobody |+| `.id-box`, the copy-IDs fallback textarea | dismiss | takes the box away |+| `#secNew`, the sections modal's foot field | close modal | closes the modal |+| `.sec-nm` / `.sec-ld` / `.sec-hex`, the sections row and colour editors | close inner | closes the row editor; the modal stays up |+| `#editor` fields: `ncName ncOwner ncStart ncEnd ncDesc ncProv`, `imName imOwner imStart imEnd imDesc imProv`, `deStart deEnd deNote`, `meName meDate meDesc`, `bStart bEnd bShiftAll bShiftS bShiftE` | close panel | closes the panel it is in |++Only ONE of them was a plain field, which is the point: the filter box was the outlier, and+the chokepoint is written so the next plain field somebody adds gets the behaviour without+anybody remembering to wire it. `v55.mjs` proves the type coverage with a probe field it+plants outside every owned container, in text, search, number, date and textarea.++Every owned field was checked for the thing a chokepoint would otherwise have to repair: a+focus left stranded on a node that has been thrown away. All of them already end with the+field removed from the document or explicitly blurred, so `document.activeElement` is+`BODY` after every single one. That is asserted per field rather than assumed.++### 2. The Clear button beside the filter box is gone++Drew: "clearing happens naturally when I remove text from the filter text bar."++**What the button actually did**, for the record, since it is the thing being removed: one+click set `tblOwner`, `tblStatus` and `tblSearch` all to empty, wrote the empty owner into+`prefs.tableOwner`, saved prefs and re-rendered the table. Three filters, one gesture.++**What remains.** Each of the three keeps its own way back, which is what makes the button+redundant rather than missed: the owner dropdown opens on "All owners", the status dropdown+opens on "All", and the text box is cleared by emptying it, by deleting the text, by+select-all and Delete (nothing in this app claims Ctrl+A, so inside a field it is the+browser's own select-all; asserted), or by the search field's own native clear affordance,+which is visible in both themes. What is genuinely gone is the ONE-GESTURE reset of all+three at once; there is no replacement for that and it was not asked for.++The row reflows on its own. `.table-tools` is a flex line with `gap: 8px`, so taking a child+out closes the space with no rule change and no leftover hole; `.table-count` keeps its+`margin-left: auto` and stays pinned right. `v55.mjs` measures every adjacent pair in the+row against the row's own gap plus the neighbours' declared margins, in both themes, and+checks the row does not overflow. `.tt-btn` stays in the stylesheet because the Copy IDs+button still uses it.++Escape still PRESERVES the filter text. The Clear button being gone does not change that: a+key that silently wipes a filter someone built is a key nobody presses twice, and clearing+is now a deliberate act on the text itself.++### 3. The bar-note field committed on Escape, and threw every time++Found by the field-by-field pass in 1, present since the feature was written, and repaired+here because "Escape cancels the edit" was simply not true of this field.++`editNoteInline`'s `commit()` had a `parentNode` guard but no latch. Taking a FOCUSED input+out of the document fires its blur from INSIDE the removal, and the blur handler here is+`commit(true)`. So Escape ran `commit(false)`, reached `inp.remove()`, was re-entered by its+own blur as `commit(true)`, and that second pass SAVED the note the first pass had just been+told to throw away, snapshot, undo entry, sidecar write and all. Then it removed the node+out from under the `remove()` still on the stack, which threw `NotFoundError` on every+Escape and every Enter. The `parentNode` guard could not catch it, because during the outer+removal the node still has its parent.++One `done` latch fixes both halves, and it is the same latch `editLabelInline` and+`startCellEdit` have always had. Escape now cancels: nothing on the bar, nothing in the+sidecar, no console error. Enter still commits, once. Measured before and after in+`g55/noterepro.mjs`.++### Suites++`v55.mjs` on the g55 bench (port 9081), 197 assertions: the filter box, the next-keypress+proof, the one-Escape-one-action proof against a live selection and against dependency+mode, the field-type coverage, all seven owned classes, all six panel shapes field by+field, the sections modal's inner-then-outer stack, the Clear button's absence, the row+geometry in both themes, and a reload.++Four standing suites clicked `#tblClear` and were reworked to clear by emptying the three+filters and letting their own handlers run, which reaches the same end state: `w/cols.js`+(in the standing battery), plus `w/cols2.js`, `w/nav.js`, `w/tsel.js` and `gtest/t3.mjs`,+which are not. No suite asserted a stranded-focus quirk, so nothing had to be updated+deliberately in the other direction.++Full standing battery green, `mssel` and `msphase` byte-identical against 0.54.0.+ ## Build notes, 2026-08-24 (0.54.0, the silent save-blocked tab)  One change, and it is a data-loss fix rather than a feature. `gantt.html` only; `server.js`
gantt.html+77−6
@@ -1327,8 +1327,12 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va         <option value="complete">complete</option>         <option value="cancelled">cancelled</option>       </select>+      <!-- The Clear button stood here until 0.55.0. It reset all three filters at once,+           which is a button for a state the row already shows you how to leave: the two+           dropdowns have All at the top of them and the text box empties by deleting the+           text in it. One control fewer in a row that is already the busiest strip in the+           app, and nothing that was reachable stopped being reachable. -->       <input type="search" id="tblSearch" placeholder="Filter by text" aria-label="Filter rows by text">-      <button class="tt-btn" id="tblClear" title="Clear every filter">Clear</button>       <span class="sort-lock-note" id="sortLockNote" style="display:none"></span>       <span class="table-count" id="tblCount"></span>       <button class="tt-btn" id="btnCopyIds" style="display:none" title="Copy the selected items' permanent IDs, one per line with the name, to paste into an AI chat for batch edits">Copy IDs</button>@@ -2766,6 +2770,20 @@ function isTypingTarget(el){   const t=(el.tagName||'').toUpperCase();   return t==='INPUT'||t==='TEXTAREA'||t==='SELECT'; }+// The field types Escape blurs. A SELECT is deliberately not one of them: its Escape+// belongs to the list that is open over it, native or ours. A date field IS one, and the+// blur is all that changes there, so the arrow keys and typing still walk its segments.+const ESC_TEXT_TYPES={text:1,search:1,number:1,tel:1,url:1,email:1,password:1,+  date:1,month:1,week:1,time:1,'datetime-local':1};+// The fields whose Escape is already spoken for. See the listener in wireGlobalKeys.+const ESC_OWNED='.label-edit,.bar-note-edit,.cell-input,.cl-new-inp,.id-box,.sec-back,#editor';+function isEscBlurTarget(el){+  if(!el||!el.tagName)return false;+  const t=el.tagName.toUpperCase();+  if(t==='TEXTAREA')return true;+  if(t!=='INPUT')return false;+  return !!ESC_TEXT_TYPES[(el.type||'text').toLowerCase()];+} function appIsIdle(){   if(tblEditing||labelEditing||rowDrag||laneDrag||dragState||reorderState||gesture)return false;   if(editorEl.classList.contains('show'))return false;@@ -2788,6 +2806,46 @@ document.addEventListener('keydown',e=>{   if(e.key==='Escape'){e.preventDefault();e.stopPropagation();} },true); document.addEventListener('mousedown',()=>{ if(delArm)disarmDelete(); if(depArm)disarmDepDelete(); },true);+// Escape gets you out of a field. One rule, in one place.+//+// Escape pressed with the caret in a plain text field takes the FOCUS out of the field+// and does nothing else. Until 0.55.0 the table's filter box was a dead end for the+// keyboard: it stops its own keydown, which is right (the page's chords must stay out of+// a field someone is typing in), but nothing ever blurred it, so after typing a filter+// every global key in the app was gone until the user reached for the mouse.+//+// IT DOES NOT CLEAR THE FIELD. A filter someone typed is work, and a key that silently+// wipes it is a key nobody presses twice. Emptying the box is what clears the filter.+// preventDefault is load-bearing for exactly that reason: a search input clears ITSELF+// on Escape in WebKit-derived browsers, and the text is the one thing this must not touch.+//+// WHATEVER ALREADY OWNED THE KEY KEEPS IT. Several fields have a real Escape of their own,+// and every one of them already ends with the field removed or explicitly blurred, so the+// focus is never stranded either way. They are listed here rather than patched one by one,+// so this stays the single place the whole rule is written down:+//+//   .label-edit     the chart's inline name editor    Escape cancels the edit+//   .bar-note-edit  the note typed onto a bar         Escape cancels the edit+//   .cell-input     a table cell editor               Escape cancels the edit+//   .cl-new-inp     the owner list's type-in row      Escape closes the list+//   .id-box         the copy-IDs fallback box         Escape dismisses the box+//   .sec-back       the sections modal                Escape closes the open row editor, then the modal+//   #editor         every panel field                 Escape closes the panel+//+// CAPTURE, and it stops the event dead. One Escape is one action: blurring the filter box+// must not ALSO exit dependency mode or close whatever is open behind it, and stopping the+// event here is what guarantees the app-level handler below never sees the same keypress.+// The next Escape, with nothing focused, does exactly what it always did.+document.addEventListener('keydown',e=>{+  if(e.key!=='Escape'||e.altKey||e.ctrlKey||e.metaKey)return;+  // A live drag or reorder owns Escape outright, whatever happens to hold focus.+  if(rowDrag||laneDrag||dragState||(reorderState&&reorderState.active))return;+  const el=document.activeElement;+  if(!isEscBlurTarget(el))return;+  if(el.closest&&el.closest(ESC_OWNED))return;+  e.preventDefault(); e.stopPropagation();+  try{ el.blur(); }catch(_){}+},true); document.addEventListener('keydown',e=>{   // Undo and redo live in the same handler and are therefore guarded identically,   // which is the point: whatever stops Ctrl+Z from firing stops Ctrl+Shift+Z too.@@ -6197,8 +6255,20 @@ function editNoteInline(id){   inp.className='bar-note-edit';inp.value=r.note||'';inp.maxLength=24;   inp.addEventListener('mousedown',e=>e.stopPropagation());   inp.addEventListener('click',e=>e.stopPropagation());+  // LATCHED, the same way the label editor and the table's cell editor are, and for a+  // reason 0.55.0's field-by-field pass over Escape turned up. Taking a FOCUSED input out+  // of the document fires its blur from INSIDE the removal, and the blur handler here is+  // commit(true). So Escape used to run commit(false), reach inp.remove(), be re-entered+  // by its own blur as commit(true), and that second pass SAVED the note the first pass+  // had just been told to throw away, snapshot, sidecar write and all. Then it removed the+  // node out from under the remove() still on the stack, which threw NotFoundError on+  // every single Escape and every single Enter. The parentNode guard could not catch it:+  // during the outer removal the node still has its parent. One latch answers both halves,+  // so Escape cancels, Enter commits once, and neither of them throws.+  let done=false;   const commit=(save)=>{-    if(!inp.parentNode)return;+    if(done||!inp.parentNode)return;+    done=true;     if(save&&(r.note||'')!==inp.value){snapshot(id);r.note=inp.value;updateBar(id);scheduleSave('bar-note');}     inp.remove();   };@@ -9093,10 +9163,11 @@ ownerSel.addEventListener('change',()=>{prefs.tableOwner=ownerSel.value;savePref statusSel.addEventListener('change',renderTable); searchInp.addEventListener('keydown',e=>e.stopPropagation()); searchInp.addEventListener('input',()=>{clearTimeout(tblSearchTimer);tblSearchTimer=setTimeout(renderTable,120);});-$('tblClear').addEventListener('click',()=>{-  ownerSel.value='';statusSel.value='';searchInp.value='';-  prefs.tableOwner='';savePrefs();renderTable();-});+// The Clear button used to be wired here. It set the owner select, the status select and+// the search box back to empty, wrote the empty owner into prefs and re-rendered. Each of+// those three still has its own way back (All owners, All, and deleting the text, which+// the input event below already answers), so the button was a fourth control for a job+// the other three were already doing. }  
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.54.0",+  "version": "0.55.0",   "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page 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. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Five statuses, including cancelled, which keeps the row and its history on the plan while taking its hours out of every total. 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.54.0",+  "version": "0.55.0",   "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page 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. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Five statuses, including cancelled, which keeps the row and its history on the plan while taking its hours out of every total. 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": {@@ -86,4 +86,4 @@     "name": "Drew Owens",     "email": "[email protected]"   }-}\ No newline at end of file+}