← Commit history

Publish 0.69.0

Drew Owens ·97190245e8 · ·parent 7c86db7
7 files changed +134−5
README.md+1
@@ -381,6 +381,7 @@ long editing run. | Ctrl+Z | Undo | | Ctrl+Shift+Z | Redo, also Ctrl+Y. Replays only what undo took back, so any new edit clears it | | Up / Down | Move the selected row in the table, following the visible order under sorts and filters. They never scroll the pane by themselves; it moves only enough to keep the selected row in view |+| Up / Down in a list | Every list of the app's own walks with the arrows (0.69.0): the status pill's and the owner picker's on the chart, the phase, status, type and "waits on" lists in the editors, the Table's cells, and the Export and Import menus. Down and Up move the mark, Home and End jump to the ends, a letter jumps to the next entry starting with it, Enter takes the marked entry, Escape closes the list changing nothing, Tab closes it on the way to the next control | | Enter | Edit the selected row's name. Inside a cell, commit and step down one row | | Delete | Delete the selected row, undoable. Only when a row is selected and no field is open, so Delete inside an editor just edits text. Backspace is deliberately not bound | | Shift+D | Dependency mode: click the task that comes first, then the one that waits on it. The **Deps** button glows in the accent for as long as the mode is on, and its tooltip is the instruction. The row under the pointer lights up across both panes, tinted in its own phase colour, so you can see which task you are about to click. The armed first task wears the normal selection outline. Clicking a pair that already has a dependency removes it. Click an arrow to pick it, then Delete twice to remove that one. Stays on so you can chain them. Esc lets go of a picked arrow, then the pick, then leaves |
SKILL.md+1
@@ -259,6 +259,7 @@ rather than a category. - **Deps column** in the table: a narrow read-only indicator, needs and blocks counts with the names on hover, blank when there are none. Clicking it opens the editor where dependencies are changed. Sortable by total count - **Ctrl+Z** to undo (100-level stack) - **Ctrl+Shift+Z** (or **Ctrl+Y**) to redo. Redo only ever replays what undo just took back, so any new edit clears it. It covers every undoable action symmetrically: field edits, snapped drags, row and lane moves, deletes, creates (the row comes back with the same id and stable key), milestone edits and the whole-plan shift. Inside a text field the field's own undo and redo win, exactly as Ctrl+Z already does. Redo is logged to the changelog as its own event, tagged `redo`+- **Every list walks with the arrows** (0.69.0): the status pill's and the owner picker's on the chart, the phase, status, type and "waits on" lists in the item editor and the New panel, the Table's cells, and the Export and Import menus. Down and Up move the mark, Home and End jump to the ends, a letter jumps to the next entry starting with it, Enter takes the marked entry, Escape closes the list changing nothing, and Tab closes it on the way to the next control. A list open from a click (the status pill's) holds no focus, so its keys are read while it is open; the page's own arrow keys never see them. - **Up / Down** in the table move the selected row, following the visible order under any sort or filter. They never scroll the pane on their own: the browser's default is suppressed and the pane moves only enough to keep the selected row in view. The first press selects the first visible row. In the chart the arrows are left alone - **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
docs/BUILD-NOTES.md+30
@@ -3,6 +3,36 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-14 (0.69.0, every list walks with the arrow keys)++Drew, with a picture of the status pill's list open on the chart: "all dropdown menus+should be navigable via arrow keys". 0.66.1 gave the keyboard to the owner picker and to+every select that goes through wireAppSelect (the editors' lists, where the select holds+the focus). Two kinds of list were left out, for the same reason: nothing on the page held+a focus that could walk them. The status pill's list opens from a click on a span and reads+an offscreen select that is never focused; the Export, Import, Help and who-card menus open+from a button that keeps the focus, and the menu had no keys at all.++Both now read the keyboard on the window in the capture phase while they are open, the way+the owner picker does, so the chart's own Up/Down (row focus) and the Escape chain never+see a key the list took. For the pill, one small helper (listKeyStep) applies to the select+the keys a focused select answers to: arrows step, Home/End jump, a letter goes to the next+option starting with it, wrapping; the list follows the choice (the `on` mark), Enter fires+the same change a click makes, Escape and Tab close it and change nothing. For the menus,+the arrows move a mark among the actionable items (help rows are text and are skipped), the+pointer moves the same mark so the two never disagree, Enter clicks the marked item, Escape+closes, Tab closes on the way to the next control. The mark wears the pointer's highlight+plus the accent bar every list of ours uses.++Native selects (the Table bar's owner and status filters, the grain, the move-to list) and+the tag boxes' datalist already walk with the arrows in the browser's own popup and were+left alone.++Tests: five checks on the status pill (opens on the row's status, Down walks without+committing, Enter saves, a letter jumps, Escape changes nothing) and four on the Export+menu (Enter on the button opens it, the arrows mark, Enter downloads the marked format,+Escape closes).+ ## Build notes, 2026-10-14 (0.68.0, who can open a plan)  Step 2 of the sharing plan: the gate that must be up before the real plan goes on a public
gantt.html+64−3
@@ -927,6 +927,9 @@ body.ed-dragging { user-select: none; -webkit-user-select: none; } .dropdown.show { display: block; } .dropdown .dd-item { padding: 6px 14px; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text2); cursor: pointer; transition: background 0.15s; display: flex; align-items: center; gap: 8px; } .dropdown .dd-item:hover { background: rgba(255,255,255,0.04); color: var(--text); }+/* The keyboard's mark on a menu item (0.69.0): the pointer's highlight, with the accent bar+   every list of ours uses for the walked-to entry. */+.dropdown .dd-item.active { background: rgba(255,255,255,0.04); color: var(--text); box-shadow: inset 2px 0 0 var(--accent); } .dropdown .dd-sep { height: 1px; background: var(--border-muted); margin: 4px 0; } .dropdown .dd-label { padding: 4px 14px; font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text2); } @@ -8182,6 +8185,34 @@ function setEditorKeys(fn){ // same click that tells an open panel to close, and must survive it. function closeEditor(){edSubject=null;setEditorKeys(null);if(cellList&&editorEl.contains(cellList.sel))closeCellList();if(ownerCombo&&editorEl.contains(ownerCombo.inp))closeOwnerCombo();editorEl.classList.remove('show','square','compact');editorEl.innerHTML='';} function closeMenus(){exportMenu.classList.remove('show');importMenu.classList.remove('show');}+// ── The keyboard on the menus (0.69.0) ──+// Export, Import, Help and the who-card are one kind of menu, opened from a button that+// keeps the focus. While one is open the arrows walk its items, Home and End jump to the+// ends, Enter takes the marked item, Escape closes the menu, and Tab closes it on the way+// to the next control. The mark is the same highlight the pointer gives, and the pointer+// moves it too, so the two never disagree. Help rows are text, not choices, and are+// skipped. Read on the window in the capture phase so the page's own arrow keys (row+// focus) and Escape chain never see a key the menu took.+function openMenuEl(){ return [exportMenu,importMenu].find(m=>m.classList.contains('show'))||null; }+function menuItems(m){ return [...m.querySelectorAll('.dd-item')].filter(it=>it.style.cursor!=='default'); }+function menuMark(m,i){ const its=menuItems(m); its.forEach((it,j)=>it.classList.toggle('active',j===i)); if(its[i]&&its[i].scrollIntoView)its[i].scrollIntoView({block:'nearest'}); }+window.addEventListener('keydown',e=>{+  const m=openMenuEl(); if(!m)return;+  if(e.key==='Escape'){ e.preventDefault(); e.stopImmediatePropagation(); closeMenus(); return; }+  if(e.key==='Tab'){ closeMenus(); return; }+  const its=menuItems(m); if(!its.length)return;+  const cur=its.findIndex(it=>it.classList.contains('active'));+  if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Home'||e.key==='End'){+    e.preventDefault(); e.stopImmediatePropagation();+    menuMark(m,e.key==='Home'?0:e.key==='End'?its.length-1:e.key==='ArrowDown'?(cur<0?0:Math.min(its.length-1,cur+1)):(cur<0?its.length-1:Math.max(0,cur-1)));+    return;+  }+  if(e.key==='Enter'&&cur>=0){ e.preventDefault(); e.stopImmediatePropagation(); its[cur].click(); }+},true);+[exportMenu,importMenu].forEach(m=>m.addEventListener('mousemove',e=>{+  const it=e.target.closest('.dd-item'); if(!it||it.style.cursor==='default')return;+  menuItems(m).forEach(x=>x.classList.toggle('active',x===it));+}));  // ── Drag (edge-resize, body-move horizontal, reorder vertical) ── let dragState=null;@@ -11557,6 +11588,27 @@ function syncCellList(){     if(on&&o.scrollIntoView)o.scrollIntoView({block:'nearest'});   }); }+// One keyboard for a list of ours whose select is NOT the focused control (the status+// pill's: it opens from a click on a pill, so nothing on the page holds the focus that+// would walk it). The same keys a focused select answers to, applied to the select by+// hand: the arrows step, Home and End jump, a letter goes to the next option starting with+// it, wrapping. Returns true when the key was the list's, whether or not it moved.+function listKeyStep(sel,ev){+  const n=sel.options.length; if(!n)return false;+  const i=sel.selectedIndex; let to=null;+  if(ev.key==='ArrowDown')to=Math.min(n-1,i+1);+  else if(ev.key==='ArrowUp')to=Math.max(0,i-1);+  else if(ev.key==='Home'||ev.key==='PageUp')to=0;+  else if(ev.key==='End'||ev.key==='PageDown')to=n-1;+  else if(ev.key.length===1&&!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&/\S/.test(ev.key)){+    const k=ev.key.toLowerCase();+    for(let s=1;s<=n;s++){ const j=(i+s)%n; if(sel.options[j].textContent.trim().toLowerCase().startsWith(k)){ to=j; break; } }+    if(to===null)return true;          // a letter nothing starts with: the list's, still+  }+  if(to===null)return false;+  sel.selectedIndex=to;+  return true;+} // The status pill in the label column is editable in place. It reuses the table's // custom option list (openCellList) rather than a native <select> popup, for the // same reason the table does (no double-list bug, on-brand styling), and it commits@@ -11581,12 +11633,21 @@ function openStatusPill(r,pillEl){   // the temporal dead zone. That throw only fires for a row carrying a status word none   // of ours matches, which is why nobody had met it; the rename retires it.   const cleanup=()=>{ closeCellList(); if(sel.parentNode)sel.remove();-    document.removeEventListener('mousedown',outside,true); document.removeEventListener('keydown',escKey,true); };+    document.removeEventListener('mousedown',outside,true); window.removeEventListener('keydown',escKey,true); };   const outside=ev=>{ if(!ev.target.closest('.cell-list'))cleanup(); };-  const escKey=ev=>{ if(ev.key==='Escape'){ ev.stopPropagation(); cleanup(); } };+  // THE KEYBOARD (0.69.0). The list walks with the arrows and the list follows the choice,+  // Enter takes it (the same change the click makes), Escape and Tab close it and change+  // nothing. Read on the window in the capture phase, so the chart's own arrow keys (row+  // focus) never see a key the list took.+  const escKey=ev=>{+    if(ev.key==='Escape'){ ev.preventDefault(); ev.stopImmediatePropagation(); cleanup(); return; }+    if(ev.key==='Tab'){ cleanup(); return; }+    if(listKeyStep(sel,ev)){ ev.preventDefault(); ev.stopImmediatePropagation(); syncCellList(); return; }+    if(ev.key==='Enter'){ ev.preventDefault(); ev.stopImmediatePropagation(); sel.dispatchEvent(new Event('change',{bubbles:true})); }+  };   sel.addEventListener('change',()=>{ const v=sel.value; cleanup(); commitCell(r,'status',v); renderTable(); });   // Deferred, so the click that opened the list does not immediately close it.-  setTimeout(()=>{ document.addEventListener('mousedown',outside,true); document.addEventListener('keydown',escKey,true); },0);+  setTimeout(()=>{ document.addEventListener('mousedown',outside,true); window.addEventListener('keydown',escKey,true); },0); }  // ── One picker per kind of field (0.65.0) ──
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.68.0",+  "version": "0.69.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. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping 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+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.68.0",+  "version": "0.69.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. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping 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": {
test/ui_test.mjs+36
@@ -1652,6 +1652,22 @@ async function editorFlows(browser, S) {   await page.click('.owner-pill[data-pick="1"]'); await settle(page, 150);   await page.keyboard.press('Escape'); await page.keyboard.press('Escape'); await settle(page, 150);   check('Escape twice closes the list, then the field, changing nothing', !(await page.$('.pill-owner-inp')) && rowOf(await savedPlan(S.url), r => r.id === 'p0t1').owner === 'John');+  // The status pill's list walks with the arrows too (0.69.0). It opens from a click, so+  // the keys are read while it is open; Enter takes the choice, Escape leaves it.+  const pickPill = () => page.evaluate(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.querySelector('.name-text')?.textContent === 'Lane 1 task 2'); e.scrollIntoView({ block: 'center' }); e.querySelector('.status-pill').dataset.pick = '1'; });+  const onOpt = () => page.evaluate(() => (document.querySelector('.cell-list .cl-opt.on') || {}).textContent || '');+  await pickPill(); await page.click('.status-pill[data-pick="1"]'); await settle(page, 150);+  check('the status pill opens the list on the row\'s status', (await onOpt()) === 'not started', await onOpt());+  await page.keyboard.press('ArrowDown'); await settle(page, 80);+  check('Down walks the list without committing, and the row keeps its focus where it was', (await onOpt()) === 'in progress' && rowOf(await savedPlan(S.url, { minWait: 0 }), r => r.id === 'p0t1').status === 'not-started', await onOpt());+  await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); await settle(page, 400);+  check('Enter takes the choice and the row is saved with it', !(await page.$('.cell-list')) && rowOf(await savedPlan(S.url), r => r.id === 'p0t1').status === 'complete', JSON.stringify(rowOf(await savedPlan(S.url, { minWait: 0 }), r => r.id === 'p0t1').status));+  await undo(page); await settle(page, 300);+  await pickPill(); await page.click('.status-pill[data-pick="1"]'); await settle(page, 150);+  await page.keyboard.type('b'); await settle(page, 80);+  check('a letter jumps to the option starting with it', (await onOpt()) === 'blocked', await onOpt());+  await page.keyboard.press('Escape'); await settle(page, 150);+  check('Escape closes the list, changing nothing', !(await page.$('.cell-list')) && rowOf(await savedPlan(S.url), r => r.id === 'p0t1').status === 'not-started');    section = 'editor: item';   const t1 = rowOf(plan0, r => r.name === 'Lane 1 task 1');@@ -1832,6 +1848,26 @@ async function menuFlows(browser, S) {     got[fmt] = f ? readFileSync(join(dl, f), 'utf8') : '';     check(`Export ${fmt} downloads a file`, !!f, 'no download appeared');   }+  // The keyboard on the menu (0.69.0): Enter on the button opens it, the arrows walk it,+  // Enter takes the marked item, Escape closes it.+  {+    // The loop above already downloaded <title>.csv, and a CDP-driven download with the+    // same name overwrites it instead of adding a file, so that one is cleared first.+    for (const x of readdirSync(dl)) if (x.endsWith('.csv')) rmSync(join(dl, x), { force: true });+    const before = new Set(readdirSync(dl));+    await page.focus('#btnExport'); await page.keyboard.press('Enter'); await settle(page, 150);+    check('Enter on the Export button opens its menu', await page.$eval('#exportMenu', m => m.classList.contains('show')));+    await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown'); await settle(page, 100);+    const marked = await page.$eval('#exportMenu', m => (m.querySelector('.dd-item.active') || { dataset: {} }).dataset.fmt);+    check('the arrows walk the menu, marking an item', marked === 'csv', String(marked));+    await page.keyboard.press('Enter');+    let f = null;+    for (let i = 0; i < 50 && !f; i++) { await sleep(100); f = readdirSync(dl).find(x => !before.has(x) && !x.endsWith('.crdownload')); }+    check('Enter takes the marked item', !!f && /\.csv$/.test(f), String(f));+    await page.click('#btnExport'); await settle(page, 100);+    await page.keyboard.press('Escape'); await settle(page, 100);+    check('Escape closes the menu', !(await page.$eval('#exportMenu', m => m.classList.contains('show'))));+  }   const n = spanOf(await exportPlan(S.url)).n;   let parsed = null; try { parsed = JSON.parse(got.json); } catch {}   check('the JSON export is the plan (same number of rows as the server holds)', !!parsed && spanOf(parsed).n === n, parsed ? String(spanOf(parsed).n) : 'unparseable');