← Commit history

Publish 0.72.0

Drew Owens ·1c57b88abb · ·parent e10bbcc
5 files changed +144−13
docs/BUILD-NOTES.md+21
@@ -3,6 +3,27 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-27 (0.72.0, typing highlights in every list; only Enter or a click picks)++Drew, with a Table section cell open: "I need to be able to type to highlight existing+entries, currently the first keystroke picks an item from list. Apply this to all+dropdowns in the app." The Table's select cells commit on `change`, and the browser fires+`change` for its own type-ahead and for the arrows, so the first key saved. Every list now+shares one keyboard: the arrows, Home/End and typing move the mark by hand (preventDefault,+so the browser never changes the value for a key); letters typed within 0.9 s build one+search, matched at the start of an entry first and then anywhere in it; the same letter+again walks the entries starting with it; Enter or a click picks; Escape cancels.+Covered: the Table's section and status cells, the item editor and New panel lists (which+already waited for Enter but had single-letter jumps), the status pill, the Table toolbar's+owner and status filters, the legend's owner filter, the timeline grain, Setup's move-to+list on removing a section, the Setup Sharing and Chat lists, and the Export, Import and+Help menus. The filters and the grain used the browser's own popup and applied on every+key; they now open the app's list and apply on Enter or a click.++Tests: a type-ahead flow (one letter highlights without saving, "fa" finds factoryOS, Enter+saves; arrows move without saving and Escape leaves it; the owner filter highlights then+applies on Enter; the editor's section list; the status pill's "bl"; the Export menu).+ ## Build notes, 2026-10-22 (0.71.1, a mouse pick in the editor's lists closed the editor)  Drew, with the milestone editor's status list open: "if I click on an item in this list,
gantt.html+65−11
@@ -5079,7 +5079,8 @@ ownerPick.innerHTML='<span class="op-label">Owner</span>'; ownerPickSel.id='ganttOwner'; ownerPickSel.setAttribute('aria-label','Owner filter'); ownerPickSel.title="Show only one person's rows, or the ones nobody owns yet. Your view only, it does not change the plan."; ownerPick.appendChild(ownerPickSel);-ownerPickSel.addEventListener('change',()=>setOwnerFilter(ownerPickSel.value));+wireAppSelect(ownerPickSel);+ownerPickSel.addEventListener('apppick',()=>setOwnerFilter(ownerPickSel.value)); overdueChip.type='button'; overdueChip.id='overdueChip'; overdueChip.className='legend-item overdue-chip'; overdueChip.addEventListener('click',()=>{ prefs.overdueOnly=!prefs.overdueOnly; savePrefs(); applyTagView(); }); currentChip.type='button'; currentChip.id='currentChip'; currentChip.className='legend-item current-chip';@@ -8047,6 +8048,7 @@ function openDelete(row,i,held){   }   row.appendChild(wrap);   const sel=wrap.querySelector('.sec-dest');+  if(sel)wireAppSelect(sel);   if(sel&&held&&held.dest&&[...sel.options].some(o=>o.value===held.dest))sel.value=held.dest;   row._secHold=()=>({k,reopen:(r,j)=>openDelete(r,j,{dest:sel?sel.value:''})});   wrap.querySelector('.sec-no').addEventListener('click',()=>renderSections());@@ -8279,7 +8281,15 @@ window.addEventListener('keydown',e=>{     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(); }+  if(e.key==='Enter'&&cur>=0){ e.preventDefault(); e.stopImmediatePropagation(); its[cur].click(); return; }+  // Typing marks the item it starts (0.72.0), the same search as every list.+  if(e.key.length===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&/\S/.test(e.key)){+    e.preventDefault(); e.stopImmediatePropagation();+    const now=performance.now(); if(now-(m._taAt||0)>TA_GAP_MS)m._ta=''; m._taAt=now; m._ta=(m._ta||'')+e.key.toLowerCase();+    const t=it=>it.textContent.trim().toLowerCase(), q=m._ta;+    let j=its.findIndex(it=>t(it).startsWith(q)); if(j<0)j=its.findIndex(it=>t(it).includes(q));+    if(j>=0)menuMark(m,j);+  } },true); [exportMenu,importMenu].forEach(m=>m.addEventListener('mousemove',e=>{   const it=e.target.closest('.dd-item'); if(!it||it.style.cursor==='default')return;@@ -9944,7 +9954,8 @@ $('btnMsStyle').addEventListener('click',()=>{ syncMsBtn();  $('grainSel').value = G();-$('grainSel').addEventListener('change',function(){+wireAppSelect($('grainSel'));+$('grainSel').addEventListener('apppick',function(){   prefs.granularity = this.value;   savePrefs();   rebuildTimeline();@@ -11705,6 +11716,27 @@ function syncCellList(){ // 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.+// TYPE-AHEAD (0.72.0). Typing in any list of ours HIGHLIGHTS, it never picks: letters typed+// within 0.9 s of each other build one search ("fa" finds factoryOS past Final check), the+// first entry starting with it is marked, else the first containing it, and the same letter+// again steps through the entries that start with it. The browser's own type-ahead on a+// select CHANGES the value and fires change on the first key, which the Table's cells read+// as the pick (Drew, 2026-10-27: "the first keystroke picks an item from list").+const TA_GAP_MS=900;+function taActive(sel){ return !!sel._ta&&performance.now()-(sel._taAt||0)<TA_GAP_MS; }+function typeAheadIndex(sel,ch){+  const now=performance.now();+  if(now-(sel._taAt||0)>TA_GAP_MS)sel._ta='';+  sel._taAt=now;+  const n=sel.options.length, i=Math.max(0,sel.selectedIndex);+  const lab=j=>sel.options[j].textContent.trim().toLowerCase();+  let q=(sel._ta||'')+ch.toLowerCase(); sel._ta=q;+  if(q.length>1&&[...q].every(c=>c===q[0]))q=q[0];       // "bbb" walks the b's+  const from=q.length===1?i+1:i;                          // a longer search may stay put+  for(let s=0;s<n;s++){ const j=(from+s)%n; if(lab(j).startsWith(q))return j; }+  for(let s=0;s<n;s++){ const j=(from+s)%n; if(lab(j).includes(q))return j; }+  return -1;+} function listKeyStep(sel,ev){   const n=sel.options.length; if(!n)return false;   const i=sel.selectedIndex; let to=null;@@ -11712,15 +11744,27 @@ function listKeyStep(sel,ev){   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+  else if(ev.key.length===1&&!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&(/\S/.test(ev.key)||taActive(sel))){+    const j=typeAheadIndex(sel,ev.key);+    if(j<0)return true;                // nothing matches: still the list's key+    to=j;   }   if(to===null)return false;   sel.selectedIndex=to;   return true; }+// The keys on a FOCUSED select of ours (0.72.0): the arrows, Home/End and typing move the+// mark by hand (preventDefault), so the browser never changes the value for a key and never+// fires change for one. Opens the list if it is closed. True when the key was one of these.+function listNavKey(sel,e,open){+  if(e.ctrlKey||e.metaKey||e.altKey)return false;+  const typing=e.key.length===1&&(/\S/.test(e.key)||taActive(sel));+  if(!typing&&!/^(ArrowUp|ArrowDown|Home|End|PageUp|PageDown)$/.test(e.key))return false;+  e.preventDefault(); e.stopImmediatePropagation();+  listKeyStep(sel,e);+  open(); syncCellList();+  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@@ -11791,8 +11835,12 @@ function wireAppSelect(sel){   });   sel.addEventListener('keydown',e=>{     if(sel.disabled)return;-    if(e.key===' '||(e.altKey&&(e.key==='ArrowDown'||e.key==='ArrowUp'))){ e.preventDefault(); e.stopImmediatePropagation(); if(isOpen())closeCellList(); else open(); return; }-    if(NAV.test(e.key)||(e.key.length===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey)){ sel._navAt=performance.now(); if(NAV.test(e.key))open(); return; }+    if((e.key===' '&&!taActive(sel))||(e.altKey&&(e.key==='ArrowDown'||e.key==='ArrowUp'))){ e.preventDefault(); e.stopImmediatePropagation(); if(isOpen())closeCellList(); else open(); return; }+    // A step of the keys (0.72.0: typing builds a search). The value moves and `change`+    // fires, flagged as a step, so a panel that shows fields by type still follows it;+    // nothing PICKS until Enter or a click.+    const before=sel.selectedIndex;+    if(listNavKey(sel,e,open)){ if(sel.selectedIndex!==before){ sel._navAt=performance.now(); sel.dispatchEvent(new Event('change',{bubbles:true})); } return; }     if(e.key==='Enter'&&isOpen()){ e.preventDefault(); e.stopImmediatePropagation(); closeCellList(); sel.dispatchEvent(new CustomEvent('apppick')); return; }     if(e.key==='Escape'&&isOpen()){ e.preventDefault(); e.stopImmediatePropagation(); closeCellList(); }   },true);@@ -12056,6 +12104,9 @@ function startCellEdit(td,caret){     // leaving it costs one Tab like every other field.     openCellList(el,td);     el.addEventListener('change',syncCellList);+    // Keys move the mark and never commit (0.72.0); Enter commits, Escape cancels, a click+    // on the list commits. Before, the browser's own step fired change, and change commits.+    el.addEventListener('keydown',e=>{ listNavKey(el,e,()=>{ if(!(cellList&&cellList.sel===el))openCellList(el,td); }); },true);     // ...and the browser's popup must never open ON TOP of it. 0.22.2 removed the     // showPicker path but left the plain one: clicking the already-focused select     // still opened the native popup, so a second click on a phase cell produced@@ -12393,8 +12444,11 @@ const b=$('btnCopyIds'); if(b)b.addEventListener('click',e=>{e.stopPropagation();copySelectedIds();}); } function wireTableFilters(){-ownerSel.addEventListener('change',()=>setOwnerFilter(ownerSel.value));-statusSel.addEventListener('change',renderTable);+// The toolbar's filters are our list too (0.72.0): typing and the arrows highlight, Enter or+// a click applies, the same as every other list.+wireAppSelect(ownerSel); wireAppSelect(statusSel);+ownerSel.addEventListener('apppick',()=>setOwnerFilter(ownerSel.value));+statusSel.addEventListener('apppick',renderTable); searchInp.addEventListener('keydown',e=>e.stopPropagation()); searchInp.addEventListener('input',()=>{clearTimeout(tblSearchTimer);tblSearchTimer=setTimeout(renderTable,120);}); // The Clear button used to be wired here. It set the owner select, the status select and
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.71.1",+  "version": "0.72.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.71.1",+  "version": "0.72.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+56
@@ -847,6 +847,7 @@ async function main() {     if (wantAny('share')) await shareFlow(browser);     if (wantAny('provenance')) await provFlow(browser);     if (wantAny('pick by mouse')) await pickFlow(browser);+    if (wantAny('type ahead')) await typeAheadFlow(browser);      if (wantAny('viewport')) {       // ── Viewport matrix ──@@ -3317,6 +3318,61 @@ async function notifyFlow(browser) {   check('the second click removes it', (await (await fetch(X.url + 'notify')).json()).configured === false && c.state === 'not set up', JSON.stringify(c));   await page.close(); fake.close(); }+// 0.72.0: typing in a list HIGHLIGHTS and never picks; letters typed together build one+// search; the arrows move the mark; only Enter or a click picks. In every list.+async function typeAheadFlow(browser) {+  section = 'type ahead';+  const names = ['Molecule storage', 'Scaffold storage', 'Final check, pack and ship', 'Traceability', 'Barcode', 'factoryOS', 'Quality and standards'];+  const X = await startServer({ project: { title: 'Type Test', appName: 'type-test' }, phases: names.map(n => ({ name: n, start: '2026-10', end: '2026-11' })),+    items: [{ type: 'task', id: 'a1', name: 'Type task', start: '2026-10-05', end: '2026-10-09', phase: 0, status: 'not-started', owner: 'Kale' },+      { type: 'task', id: 'a2', name: 'Other task', start: '2026-10-05', end: '2026-10-09', phase: 1, status: 'not-started', owner: 'Noah' }] }, 'typeahead');+  const page = await openPage(browser, X.url);+  const mark = () => page.evaluate(() => (document.querySelector('.cell-list .cl-opt.on') || {}).textContent || '');+  const row = async (id, ms = 300) => (await savedPlan(X.url, { minWait: ms })).items.find(r => r.id === id);+  // Table: the section cell.+  await view(page, 'table');+  await page.click('#tableView tr[data-key="t:a1"] td[data-field="phase"]'); await settle(page, 150);+  await page.keyboard.type('f'); await settle(page, 100);+  check('Table: one letter highlights the first section starting with it, and saves nothing', (await mark()) === names[2] && !!(await page.$('.cell-list')) && (await row('a1')).phase === 0, await mark());+  await page.keyboard.type('a'); await settle(page, 100);+  check('Table: letters typed together search together ("fa" is factoryOS)', (await mark()) === 'factoryOS' && (await row('a1')).phase === 0, await mark());+  await page.keyboard.press('Enter'); await settle(page, 400);+  check('Table: Enter takes the highlighted one', (await row('a1')).phase === 5, JSON.stringify(await row('a1')));+  // Table: the status cell, with the arrows.+  await page.click('#tableView tr[data-key="t:a2"] td[data-field="status"]'); await settle(page, 150);+  const st0 = await mark();+  await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown'); await settle(page, 100);+  check('Table: the arrows move the highlight and save nothing', (await mark()) !== st0 && (await row('a2')).status === 'not-started', `${st0} -> ${await mark()}`);+  await page.keyboard.press('Escape'); await settle(page, 300);+  check('Table: Escape leaves the cell as it was', (await row('a2')).status === 'not-started' && !(await page.$('.cell-list')));+  // The toolbar's owner filter: typing highlights, Enter applies.+  await page.focus('#tblOwner'); await page.keyboard.type('n'); await settle(page, 150);+  const rowsNow = () => page.$$eval('#tableView tbody tr[data-key]', t => t.length);+  check('Table filter: typing highlights an owner without filtering yet', (await mark()) === 'Noah' && (await rowsNow()) === 2, `${await mark()} / ${await rowsNow()}`);+  await page.keyboard.press('Enter'); await settle(page, 300);+  check('Table filter: Enter applies it', (await rowsNow()) === 1);+  await page.select('#tblOwner', ''); await settle(page, 200);+  // The item editor: typing in the section list.+  await view(page, 'gantt');+  await openItemEditor(page, 'Other task');+  await page.focus('#imPhase'); await page.keyboard.type('tr'); await settle(page, 150);+  check('Editor: typing highlights in the section list and the editor stays open', (await mark()) === 'Traceability' && !!(await page.$('.editor.show')), await mark());+  await page.keyboard.press('Enter'); await settle(page, 150);+  check('Editor: Enter takes it into the field, not yet saved', await page.$eval('#imPhase', e => e.value) === '3' && (await row('a2')).phase === 1);+  await page.keyboard.press('Escape'); await settle(page, 200);+  // The status pill on the chart: a two-letter search.+  await page.evaluate(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.textContent.includes('Other task')); e.scrollIntoView({ block: 'center' }); e.querySelector('.status-pill').dataset.ta = '1'; });+  await page.click('.status-pill[data-ta="1"]'); await settle(page, 150);+  await page.keyboard.type('bl'); await settle(page, 100);+  check('Status pill: "bl" highlights blocked and saves nothing', (await mark()) === 'blocked' && (await row('a2')).status === 'not-started', await mark());+  await page.keyboard.press('Escape'); await settle(page, 200);+  // The Export menu: typing marks an item.+  await page.click('#btnExport'); await settle(page, 150);+  await page.keyboard.type('sv'); await settle(page, 100);+  check('Export menu: typing marks the item it starts', await page.$eval('#exportMenu', m => (m.querySelector('.dd-item.active') || { dataset: {} }).dataset.fmt) === 'svg');+  await page.keyboard.press('Escape'); await settle(page, 150);+  await page.close();+} // 0.71.1: a pick made with the MOUSE in one of the editor's lists keeps the editor open // and holds the choice until Apply. It used to shut the editor unapplied: the click on // the option reached the page's "clicked outside" handler after the list had closed.