← Commit history

Publish 0.66.1

Drew Owens ·c03a8f466c · ·parent 5aceaa8
6 files changed +269−122
SKILL.md+6−6
@@ -242,7 +242,7 @@ rather than a category. - **Tag chips in the legend** (0.59.0; rows since 0.60.0). When the plan carries tags, on sections or on rows, the legend leads with a chip per tag (`# Tech 32`: the tag and how many rows choosing it would show, counting the rows in its sections and the rows tagged on their own) and the section chips stand BEHIND them: with no tag chosen they fold away behind one "20 sections" chip (click it to open them all; that is remembered as `prefs.legendSecs`), and with a tag chosen the chips of exactly the sections on screen come back after the tags. A section chip whose name prefix is a chosen tag drops the repeat ("Laser workcells" behind "# Tech"). Choosing tags filters by EFFECTIVE tags: a row's effective tags are its own plus its section's, and a row is shown when ANY of them is chosen. So every row of a chosen section is shown, tagged or not; a row tagged "long-lead" in an untagged section is shown by "long-lead", with its section header above it and the section's other rows hidden; and a row whose own tags do not match, in a section that does not match, is hidden. A section header is shown when the section matches or any of its rows does, and while the filter hides some of its rows its count reads "2 of 3". A delete or a tag edit that takes the last carrier of a chosen tag out of the plan drops it from the filter, and its Ctrl+Z puts the filter back with the tag. This holds in the chart and the table alike; the readouts, Analytics and everything else stay whole-plan. It composes with the section chips (which still switch a shown section off) and with Collapse all (which then counts only the shown sections). **Clear tags** shows everything again. The filter is view state, per person, in `prefs.tagFilter`, so it survives a refresh; a tag the plan no longer has drops out of it. A plan with no tags has no tag chips and no fold, and its legend is what it always was - **Owner and Overdue in the legend** (0.64.0), before the tag chips. **Owner** is a select listing every owner on the plan with their row count, then **Unassigned (n)** for the rows nobody owns, because on a young plan that is most of it and it has to be reachable. It is ONE choice shared with the table's owner select (`prefs.ownerFilter`, which replaces the table-only `prefs.tableOwner` and carries an old pick over), so the person picked on the chart is the person the table shows; the chart's copy hides in the table view, where the table's own sits in its toolbar. **Overdue** is a chip with a red dot and a count: a row is overdue when its deadline (a task's end, a milestone's date) is before today and its status does not settle it, complete or cancelled, read through `statusOf` so "completed", "done" and "canceled" settle it too; a blank status is still owed. It is the same rule as the Analytics Overdue card. Every overdue row carries a red edge on its label and a red ring on its bar (a red diamond on a milestone) whether or not the filter is on; clicking the chip shows only those rows (`prefs.overdueOnly`). The chip counts **tasks and milestones apart** (`Overdue 3 | ◆ 2`, also as `data-tasks` / `data-ms`), because milestones usually carry no status and would otherwise pile up as their dates pass and drown the late tasks; a milestone with no status counts as not reached until it is marked complete. **Overdue is derived, never stored:** it is worked out from the dates and statuses on every render, re-read after every edit, at midnight on a tab left open and when the tab comes back into view, and nothing is ever written to the plan, so no `Overdue` tag appears in the data, an export or the changelog, and computing it never makes another open tab stale. Both filters AND with the tags and narrow ROWS, never sections: under either, a section header shows only over rows it still has, its count reads "2 of 5", and Collapse all acts on the sections on screen. Readouts, Analytics and the exports stay whole-plan. An owner who no longer has a row drops out of the filter the way a vanished tag does - **Collapse all / Expand all** leads the legend (0.57.0), in the chart and the table. One press collapses every section to its header; when every section with rows is already collapsed it expands them all. A mixed state collapses. It is the same view state as the per-lane chevrons (`prefs.collapsed`), so it persists across a refresh, follows the chevrons (open the last lane by hand and it reads Collapse all again), and the table agrees with the chart. With everything collapsed the table says so rather than "no items match"-- **Click a bar, a milestone, or a name in the label column** to open the **item editor** (0.65.0), a floating panel beside what you clicked with every field of the row: type, section, name, owner, status, start, deadline, effort, notes, provenance, tags, the label on the bar and the dependencies. It is the ONE editor for a task or a milestone: the pencil on a row, a double-click, and the Table's Deps cell (which focuses "waits on") open the same panel. Everything in it, the dependencies included, is a draft that lands on Apply (or Enter) as one undo, and Cancel or Escape leaves the plan as it was. Through 0.64 a bar click opened a small dates-and-dependencies panel whose dependency changes landed at once, and a milestone click a name-date-description panel; both are gone+- **Click a bar or a milestone** to open the **item editor** (0.65.0), a floating panel beside what you clicked with every field of the row: type, section, name, owner, status, start, deadline, effort, notes, provenance, tags, the label on the bar and the dependencies. It is the ONE editor for a task or a milestone: the pencil on a row, a double-click on a name, and the Table's Deps cell (which focuses "waits on") open the same panel. A single click on a name in the label column renames it in place instead (0.66.1). Everything in it, the dependencies included, is a draft that lands on Apply (or Enter) as one undo, and Cancel or Escape leaves the plan as it was. Through 0.64 a bar click opened a small dates-and-dependencies panel whose dependency changes landed at once, and a milestone click a name-date-description panel; both are gone - **Drag bar edges** to resize (changes start or end date). Every timeline drag snaps to the grid the axis is currently drawing: day boundaries at the day cell size, week starts at weeks, month, quarter and year firsts at the coarser ones. Resizing the start leaves the end where it is and the mirror for the end; dragging the bar body snaps the START and preserves the DURATION, so the end lands at start plus the same number of days. The preview snaps live, line to line, and is exactly what gets committed. The shortest bar a resize can produce is the first grid line past the fixed edge, so it is never zero or negative. Milestones snap the same way. Dates set explicitly (table cells, the item modal, the batch editor, the project-start shift) are taken exactly as given and are never rounded - **Sign in with Adom is required** for every page load and every change. Unauthenticated visitors get a sign-in page and no plan data. Sessions are an HttpOnly cookie, 30 days, and survive a restart. `--no-auth` turns it off for single-user local use - **Direct-loopback GETs of `/data`, `/state`, `/changelog` and `/export/*` need no session**, so an AI agent's read loop keeps working; everything that changes the plan needs a signed-in person from any origin. A request is "direct loopback" only if it comes from 127.0.0.1 with no `x-forwarded-*`/`cf-*` headers and a loopback `Host`, because the workspace proxy also arrives from 127.0.0.1@@ -297,10 +297,10 @@ entered the same way:  | Kind | One way, everywhere | |------|---------------------|-| A task or milestone, all its fields | The **item editor**: a click on the bar, the milestone, the name, the pencil or the Deps cell. Apply or Enter commits; Cancel or Escape discards |-| One field, quickly | In place: a Table cell, or the status and owner pills on the chart's label row. Enter or a click away commits; Escape cancels |-| A pick from a fixed list (section, status, type, "waits on") | The app's own list, in the editor, the New panel, the Table and the pills alike. The keyboard is the select's own |-| An owner | One owner picker: a field with "no owner" and the plan's people listed under it, filtered as you type; a new name is just typed. In the editor, the New panel, the Table's Owner cell and the owner pill |+| A task or milestone, all its fields | The **item editor**: a click on the bar or the milestone, the pencil, a double-click on the name, or the Deps cell. Apply or Enter commits; Cancel or Escape discards |+| One field, quickly | In place: a Table cell, a click on a name in the chart's label column (rename), or the status and owner pills on the label row. Enter or a click away commits; Escape cancels |+| A pick from a fixed list (section, status, type, "waits on") | The app's own list, in the editor, the New panel, the Table and the pills alike. The keyboard is the select's own: the arrows walk the value and the open list follows, Enter takes it and closes the list (without applying a panel), Space or Alt+Down opens it, Escape closes it. "waits on" adds only on a click or Enter, never on a step of the arrows |+| An owner | One owner picker: a field with the plan's people listed under it, filtered as you type (matches first, "no owner" last); a new name is just typed. Down and Up walk the list, Enter takes the highlighted name, Tab takes it and moves on, Escape closes the list; with nothing highlighted Enter keeps what was typed. In the editor, the New panel, the Table's Owner cell and the owner pill (which opens the same field over the pill, since 0.66.1) | | Tags | One chip box: Enter or a comma adds, x removes, a click away keeps what was typed | | Effort | Hours, `2d`, `1w`, or empty for not estimated, in the editor, the New panel, the Table and the batch panel | | A date | The browser's date field, except the header's project start, which has its own picker because changing it moves the whole plan |@@ -332,7 +332,7 @@ named in its own column.  **Collapse.** Each phase header carries a chevron and a row count, and Collapse all / Expand all at the start of the legend does every lane at once. Collapsed lanes are view state in `localStorage` (`prefs.collapsed`), not plan state, so they are per-person and never written to the data file or the sidecar. Collapsing hides rows only: the date axis and Fit are unaffected. -**Label editing.** A click on a name in the chart's label column opens the item editor with the name selected, so a rename is still click, type, Enter (0.65.0; it used to be a one-line editor in place). A lane header's name is still edited in place, as on the Setup page. Dragging a task across a group header changes its phase.+**Label editing.** A click on a name in the chart's label column renames it in place, the caret where you clicked: Enter or a click away saves, Escape cancels, one undo (0.66.1; 0.65.0 opened the item editor here, which Drew did not want for a rename). A double-click upgrades the rename to the item editor, carrying what was typed. A lane header's name is still edited in place, as on the Setup page. Dragging a task across a group header changes its phase.  **The full item editor is the New panel.** Editing a task or a milestone opens exactly the layout creating one does, in the same order and with the same labels, hints and placeholders: type and lane on one row, name, owner and status, the two dates, notes, provenance. Three things differ, and each is a place where editing an existing row is not the same job as making a new one. TYPE is shown but disabled, with a tooltip saying why: a task has a start, an end and a bar while a milestone has one date and a diamond, so silently dropping one of a task's dates would not be a conversion, and being disabled it is skipped by Tab. STATUS keeps a "none" choice the create panel has no use for, so a status set by accident can be cleared. The BUTTONS are Apply, Cancel and Delete, with Delete pushed away from the other two. A milestone HIDES the start field rather than removing it, exactly as the create panel does when its type is switched to Milestone, so the two panels stay the same shape. 
docs/BUILD-NOTES.md+40
@@ -3,6 +3,46 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-14 (0.66.1, a click on a name renames it in place again)++Drew: "i should be able to edit a task name just by clicking on it, rather than edit task+popup appearing and forcing me to work in there. The edit task area itself is great, I+just don't want to see it when I click on a task name."++0.65.0 had made a click on a name in the label column open the item editor, so that every+click on a row opened the same panel. That consistency cost the commonest edit there is:+a rename now opened a 440px panel over the chart. The name is read where it is edited, the+way a Table cell is, so it goes back to the 0.64 behaviour: a click renames in place, the+caret where the click landed, Enter or a click away saves, Escape cancels, one undo. The+item editor is unchanged and still opens from the bar, the milestone, the pencil, the Deps+cell and a double-click on the name. The SKILL's Data entry table now lists the name under+"one field, quickly", beside the Table cells and the pills.++**The keyboard walks every list again.** Drew, in the same message thread: "I must be able+to use arrow keys to nav the list (used to be able to but now I can only click), so I can+hit enter and save some typing effort." The owner field was the browser's datalist until+0.65.0, which takes the arrows; 0.65.0's owner list did not. It does now, everywhere the+owner is set (the item editor, the New panel, the Table cell and the pill): Down and Up+walk it (Down on a closed field opens it), Enter takes the highlighted name without+applying the panel, Tab takes it and moves on, Escape closes the list. Nothing is+highlighted until a key or the mouse asks, so Enter on a NEW name that begins like an old+one keeps the new name; while filtering the matches come first and "no owner" last, so the+first Down lands on the best match. The keys are taken on the window in the capture phase+while the list is open, so the panel's Enter and the Table cell's Enter and Tab never see+a key the list used.++The owner pill on the chart had its own list with a type-in row at the bottom, the one+owner control the keyboard could not walk. It now opens the same owner field, laid over+the pill: type, Down, Enter, or a click away to commit, Escape twice to leave it as it was.++Two more found on the way. The pick lists in the item editor and the New panel (section,+status, type) closed on the first arrow press, because the select's change looked like a+click; they now follow the arrows and close on Enter, a click or Escape, and Space or+Alt+Down opens the app's list rather than the browser's popup on top of it. And "waits+on" added a dependency on every change, so arrowing through it would have added one for+every task passed: it now adds on a click or Enter only (an `apppick` event), and a change+with no list open and no key behind it (a script) still counts as a pick.+ ## Build notes, 2026-10-07 (0.66.0, live tabs: the end of clobbering on refresh)  Checkpoint 1 of the collaboration plan (docs/plan-collab.md). Drew: "Must avoid constantly
gantt.html+138−107
@@ -1481,16 +1481,15 @@ body.col-sizing { cursor: col-resize; user-select: none; }   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:hover, .cell-list .cl-opt.active { background: var(--surface2); color: var(--text); }+.cell-list .cl-opt.active { box-shadow: inset 2px 0 0 var(--accent); }+/* The owner pill's field (0.66.1): THE owner picker, laid over the pill it edits. */+.pill-owner-inp { position: fixed; z-index: 100000; height: 22px; box-sizing: border-box; padding: 2px 7px; border-radius: 5px; border: 1px solid var(--accent); background: var(--surface); color: var(--text); font-family: 'Satoshi', sans-serif; font-size: 12px; outline: none; box-shadow: 0 6px 18px rgba(0,0,0,0.35); } .cell-list .cl-opt.on { background: rgba(0,184,177,0.14); color: var(--accent); font-weight: 600; } /* The owner list is the choice list plus a way in for a name that is not on it yet.    The rule above the field says "the list ends here, this is the other option". */ .cell-list.owner-list { min-width: 172px; }-.cell-list .cl-new { margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--border-muted); }-.cell-list .cl-new-inp { width: 100%; box-sizing: border-box; background: var(--bg); border: 1px solid var(--border);-  border-radius: 5px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 11px;-  padding: 4px 7px; outline: none; }-.cell-list .cl-new-inp:focus { border-color: var(--accent); }+/* (0.66.1: the owner list's type-in row is gone; the pill opens the owner field.) */  /* 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@@ -3629,7 +3628,7 @@ function isTypingTarget(el){ 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-row,.tag-row,#editor';+const ESC_OWNED='.label-edit,.bar-note-edit,.cell-input,.pill-owner-inp,.id-box,.sec-row,.tag-row,#editor'; function isEscBlurTarget(el){   if(!el||!el.tagName)return false;   const t=el.tagName.toUpperCase();@@ -3701,7 +3700,7 @@ document.addEventListener('mousedown',()=>{ if(delArm)disarmDelete(); if(depArm) //   .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+//   .pill-owner-inp the owner pill's field            Escape closes its list, then the field //   .id-box         the copy-IDs fallback box         Escape dismisses the box //   .sec-row        a Setup page row editor           Escape cancels the row's edit //   .tag-row        a Setup page tag's rename box     Escape cancels the rename@@ -6599,15 +6598,17 @@ labelsEl.addEventListener('click',e=>{   // the name text also collapses any multi-select, the way clicking one item in any   // list drops the rest; building the set stays a ctrl or shift gesture.   if(isDerivedGroup(r)){ if(selected.size)clearSelection(); editLabelInline(r); return; }-  // The name opens the item editor (0.65.0), the same panel a click on the bar or the-  // milestone opens, with the name selected so a rename is still "click, type, Enter".-  // It used to open a one-line rename in place, a fourth way to edit the same row.+  // A click on the name renames it IN PLACE, the caret where the click landed (0.66.1).+  // 0.65.0 opened the item editor here instead, for consistency with the bar; Drew: "I+  // should be able to edit a task name just by clicking on it ... I don't want to see+  // [the editor] when I click on a task name." The name is the one field a person edits+  // where they read it, like a Table cell. The item editor is still a click on the bar,+  // the milestone, the pencil or the Deps cell away, and a double-click on the name+  // upgrades the rename to it, carrying what was typed.   const ntEl=e.target.closest('.name-text');   if(ntEl){-    e.stopPropagation();-    closeLabelEditors(true);-    selectOnly(r);-    openItemModal(r);+    if(selected.size)clearSelection();+    editLabelInline(r,caretIndexAt(ntEl,e.clientX,r.name||''));     return;   }   // The gap: select the row. selUid is null for anything that cannot be selected, so@@ -9972,7 +9973,9 @@ function openItemModal(r,carriedName,opts){     });   };   renderDeps();-  if(!ms)$('imAddDep').addEventListener('change',function(){ if(this.value&&!dNeeds.includes(this.value)){ dNeeds.push(this.value); renderDeps(); $('imAddDep').focus(); } });+  // On `apppick` (a click on the list, or Enter), not on `change`: the arrows walk this+  // list too, and every step of them is a change (0.66.1).+  if(!ms)$('imAddDep').addEventListener('apppick',function(){ if(this.value&&!dNeeds.includes(this.value)){ dNeeds.push(this.value); renderDeps(); $('imAddDep').focus(); } });   $('imStatus').value=statusOf(r);   $('imDesc').value=r.description||'';   $('imProv').value=p.label||'';@@ -11543,35 +11546,71 @@ function openStatusPill(r,pillEl){ // fires its change event, so nothing that reads the select had to change. function wireAppSelect(sel){   if(!sel||sel._appList)return; sel._appList=true;+  // KEYBOARD (0.66.1). The select keeps the focus, so the arrows, Home/End and type-ahead+  // are its own and move the value; the open list FOLLOWS them (it used to close on the+  // first arrow, because that change looked like a pick). Enter takes what is highlighted+  // and closes the list without applying the panel; the next Enter applies. Escape closes+  // the list. Space or Alt+Down opens it, instead of the browser's own popup on top of it.+  // `apppick` is fired for a real pick (a click, or Enter), never for a step of the+  // arrows, for a select whose pick DOES something (the editor's "waits on" adds one).+  const NAV=/^(ArrowUp|ArrowDown|Home|End|PageUp|PageDown)$/;+  const isOpen=()=>!!(cellList&&cellList.sel===sel);+  const open=()=>{ if(!isOpen()){ openCellList(sel,sel); cellList.el.addEventListener('click',()=>{ if(isOpen())closeCellList(); }); } };   sel.addEventListener('mousedown',e=>{     if(sel.disabled||e.button!==0)return;     e.preventDefault();     if(document.activeElement!==sel){ try{ sel.focus({preventScroll:true}); }catch(_){ sel.focus(); } }-    if(cellList&&cellList.sel===sel){ closeCellList(); return; }-    openCellList(sel,sel);+    if(isOpen()){ closeCellList(); return; }+    open();   });-  sel.addEventListener('change',()=>{ if(cellList&&cellList.sel===sel)closeCellList(); });-  sel.addEventListener('blur',()=>{ if(cellList&&cellList.sel===sel)closeCellList(); });-  // Escape closes the open list first; the next one closes the panel, as everywhere.-  sel.addEventListener('keydown',e=>{ if(e.key==='Escape'&&cellList&&cellList.sel===sel){ e.preventDefault(); e.stopImmediatePropagation(); closeCellList(); } },true);+  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==='Enter'&&isOpen()){ e.preventDefault(); e.stopImmediatePropagation(); closeCellList(); sel.dispatchEvent(new CustomEvent('apppick')); return; }+    if(e.key==='Escape'&&isOpen()){ e.preventDefault(); e.stopImmediatePropagation(); closeCellList(); }+  },true);+  sel.addEventListener('change',()=>{+    const nav=performance.now()-(sel._navAt||0)<200;+    if(isOpen()){+      if(nav){ syncCellList(); return; }                                   // a step of the keys+      closeCellList(); sel.dispatchEvent(new CustomEvent('apppick')); return; // a click+    }+    // A change with no list open and no key behind it (a script, an assistive tool)+    // is a pick too.+    if(!nav)sel.dispatchEvent(new CustomEvent('apppick'));+  });+  sel.addEventListener('blur',()=>{ if(isOpen())closeCellList(); }); } // THE OWNER. One picker everywhere an owner is set: the item editor, the New panel, the // Table's Owner cell and the owner pill on the chart. A field you can type into, with the-// plan's people listed under it ("no owner" first), filtered as you type; a name nobody-// has yet is just typed. It was a pop-up list on the pill and the browser's datalist-// everywhere else, which offered the same names in two different ways.+// plan's people listed under it, filtered as you type; a name nobody has yet is just+// typed. It was a pop-up list on the pill and the browser's datalist everywhere else,+// which offered the same names in two different ways.+//+// KEYBOARD (0.66.1): the datalist it replaced took the arrow keys and this did not, which+// Drew noticed at once. Down and Up walk the list (Down from a closed field opens it),+// Enter takes the highlighted name, Tab takes it and moves on, Escape closes the list.+// With nothing highlighted, Enter and Tab do what they would in that field anyway+// (commit what was typed, apply the panel, move on), so a NEW name is still just typed.+// Nothing is highlighted until a key or the mouse asks for it: a person typing a new+// name that happens to begin like an old one must not be handed the old one by Enter.+// While filtering, the matches come first and "no owner" last, so the first Down lands on+// the best match. let ownerCombo=null; function ownerOptionsHtml(cur,filter){   const f=(filter||'').trim().toLowerCase();   const owners=distinctOwners().filter(o=>!f||o.toLowerCase().includes(f));-  return `<div class="cl-opt${cur?'':' on'}" data-value="">no owner</div>`-    +owners.map(o=>`<div class="cl-opt${o===cur?' on':''}" data-value="${esc(o)}">${esc(o)}</div>`).join('');+  const none=`<div class="cl-opt${cur?'':' on'}" data-value="">no owner</div>`;+  const people=owners.map(o=>`<div class="cl-opt${o===cur?' on':''}" data-value="${esc(o)}">${esc(o)}</div>`).join('');+  return f?people+none:none+people; } function closeOwnerCombo(){   if(!ownerCombo)return;   const c=ownerCombo; ownerCombo=null;   c.el.remove();   window.removeEventListener('scroll',c.onScroll,true);+  window.removeEventListener('keydown',c.keys,true);   window.removeEventListener('resize',closeOwnerCombo); } function wireOwnerCombo(inp,onPick){@@ -11583,40 +11622,69 @@ function wireOwnerCombo(inp,onPick){     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';   };+  const mine=()=>!!(ownerCombo&&ownerCombo.inp===inp);+  const opts=()=>mine()?[...ownerCombo.el.querySelectorAll('.cl-opt')]:[];+  const setActive=i=>{+    if(!mine())return;+    const os=opts(); ownerCombo.active=i;+    os.forEach((o,k)=>{ o.classList.toggle('active',k===i); o.setAttribute('aria-selected',k===i?'true':'false'); });+    if(i>=0&&os[i]&&os[i].scrollIntoView)os[i].scrollIntoView({block:'nearest'});+  };+  const pick=v=>{ inp.value=v; closeOwnerCombo(); if(onPick)onPick(v); };+  // On the WINDOW, in the capture phase, and only while this list is open: these keys are+  // decided before the panel's Enter (Apply) and the Table cell's Enter and Tab (commit)+  // can see them, and when nothing is highlighted they are left alone for exactly those.+  const keys=e=>{+    if(!mine()||e.target!==inp)return;+    const os=opts(), a=ownerCombo.active;+    if(e.key==='ArrowDown'||e.key==='ArrowUp'){+      e.preventDefault(); e.stopImmediatePropagation();+      if(!os.length)return;+      setActive(e.key==='ArrowDown'?(a<0?0:Math.min(os.length-1,a+1)):(a<=0?-1:a-1));+      return;+    }+    if(e.key==='Enter'&&a>=0&&os[a]){ e.preventDefault(); e.stopImmediatePropagation(); pick(os[a].dataset.value); return; }+    if(e.key==='Tab'&&a>=0&&os[a]){ inp.value=os[a].dataset.value; closeOwnerCombo(); return; }   // and Tab goes on+    if(e.key==='Escape'){ e.preventDefault(); e.stopImmediatePropagation(); closeOwnerCombo(); }+  };   const open=()=>{     if(!inp.isConnected)return;     const cur=(inp.value||'').trim();-    if(!ownerCombo||ownerCombo.inp!==inp){+    if(!mine()){       closeOwnerCombo(); closeCellList();       const el=document.createElement('div'); el.className='cell-list owner-list owner-combo';+      el.setAttribute('role','listbox');       document.body.appendChild(el);       // Never steal the focus from the field, except on the list's own scrollbar.       el.addEventListener('mousedown',ev=>{ if(!onOwnScrollbar(el,ev))ev.preventDefault(); });       el.addEventListener('click',ev=>{         const o=ev.target.closest('.cl-opt'); if(!o)return;         ev.stopPropagation();-        const v=o.dataset.value;-        inp.value=v; closeOwnerCombo();-        if(onPick)onPick(v);+        pick(o.dataset.value);       });+      el.addEventListener('mousemove',ev=>{ const o=ev.target.closest('.cl-opt'); if(o){ const i=opts().indexOf(o); if(i!==ownerCombo.active)setActive(i); } });       // A scroll MOVES the list with its field rather than closing it: focusing a Table       // cell scrolls the table, and that scroll arrives a frame after the list opened.       const onScroll=ev=>{ if(inOwnPopup(ev))return; if(!inp.isConnected){ closeOwnerCombo(); return; } if(ownerCombo)place(ownerCombo.el); };       window.addEventListener('scroll',onScroll,true);+      window.addEventListener('keydown',keys,true);       window.addEventListener('resize',closeOwnerCombo);-      ownerCombo={el,inp,onScroll};+      ownerCombo={el,inp,onScroll,keys,active:-1};     }     // The whole list while the field holds nothing or a name already on it; a filter     // once the person is typing something else.     const known=!cur||distinctOwners().includes(cur);     ownerCombo.el.innerHTML=ownerOptionsHtml(cur,known?'':cur);+    ownerCombo.active=-1;     place(ownerCombo.el);   };+  inp.setAttribute('role','combobox'); inp.setAttribute('aria-autocomplete','list');   inp.addEventListener('focus',open);   inp.addEventListener('click',open);-  inp.addEventListener('input',()=>{ if(!ownerCombo||ownerCombo.inp===inp)open(); });-  inp.addEventListener('blur',()=>{ if(ownerCombo&&ownerCombo.inp===inp)closeOwnerCombo(); });-  inp.addEventListener('keydown',e=>{ if(e.key==='Escape'&&ownerCombo&&ownerCombo.inp===inp){ e.preventDefault(); e.stopImmediatePropagation(); closeOwnerCombo(); } },true);+  inp.addEventListener('input',()=>{ if(!ownerCombo||mine())open(); });+  inp.addEventListener('blur',()=>{ if(mine())closeOwnerCombo(); });+  // Down on a field whose list is closed (after Escape, or a pick) opens it, highlighted.+  inp.addEventListener('keydown',e=>{ if(e.key==='ArrowDown'&&!mine()){ e.preventDefault(); e.stopImmediatePropagation(); open(); setActive(0); } },true);   if(document.activeElement===inp)open(); } @@ -11626,86 +11694,49 @@ function wireOwnerCombo(inp,onPick){ // edit with one undo step, one sidecar override and one `owner` event in the changelog. // The two lenses cannot disagree because there is only one path. //-// It carries one thing the status list does not: a type-in row. Status is a closed set-// of five; the people on a project are an open set, so a dropdown alone would be a-// dead end the first time someone joins. Anyone already on the plan is one click, a-// new name is one line of typing, and the moment it commits distinctOwners picks it up-// so it is in this list for every other row, and in the table's filter and datalist.+// It is a field, not a list: status is a closed set of five, but the people on a project+// are an open set, so anyone already on the plan is a pick and a new name is typed. The+// moment it commits, distinctOwners picks it up for every other owner picker. function openOwnerPill(r,pillEl){   if(!r||!r.id)return;-  closeCellList(); closeLabelEditors(true);-  const cur=(r.owner||'').trim();-  const owners=distinctOwners();-  const el=document.createElement('div');-  el.className='cell-list owner-list';-  el.innerHTML=-    `<div class="cl-opt${cur?'':' on'}" data-value="">no owner</div>`-    +owners.map(o=>`<div class="cl-opt${o===cur?' on':''}" data-value="${esc(o)}">${esc(o)}</div>`).join('')-    +`<div class="cl-new"><input type="text" class="cl-new-inp" placeholder="new name" `-    +`aria-label="Assign someone not on this list" maxlength="60"></div>`;-  document.body.appendChild(el);-  // Same clamped placement the table's list uses, so a pill near the bottom of the-  // window opens upward instead of off the edge.+  closeCellList(); closeLabelEditors(true); closeOwnerCombo();+  const old=document.querySelector('.pill-owner-inp'); if(old)old.remove();+  // Since 0.66.1 the pill opens THE owner picker (the one the editor and the Table use):+  // a field laid over the pill, holding the current owner selected, with the plan's people+  // under it. It was a list with a type-in row at the bottom, the one owner control in the+  // app the keyboard could not walk.   const rect=pillEl.getBoundingClientRect();-  el.style.left=Math.max(4,Math.min(rect.left,window.innerWidth-el.offsetWidth-6))+'px';-  const below=window.innerHeight-rect.bottom;-  el.style.top=(below>el.offsetHeight+8?rect.bottom+2:Math.max(4,rect.top-el.offsetHeight-2))+'px';-+  const cur=(r.owner||'').trim();+  const inp=document.createElement('input');+  inp.type='text'; inp.className='pill-owner-inp'; inp.autocomplete='off'; inp.maxLength=60;+  inp.placeholder='owner'; inp.value=cur;+  inp.setAttribute('aria-label','Owner of '+(r.name||'this row'));+  inp.style.left=Math.max(4,Math.min(rect.left,window.innerWidth-186))+'px';+  inp.style.top=Math.round(rect.top-2)+'px';+  inp.style.width=Math.max(150,Math.round(rect.width)+40)+'px';+  document.body.appendChild(inp);   let done=false;-  // Named, so the same function object that was added is the one removed. It used to-  // be `cleanup` itself, which was symmetrical but heard the list scrolling itself.-  const onScroll=popupScrollGuard(()=>cleanup());-  const cleanup=()=>{+  const onScroll=ev=>{ if(inOwnPopup(ev))return; finish(true); };+  // One commit path, the Table cell's: commitCell(r,'owner',v), one undo, one `owner`+  // line. A click away commits what is in the field, as the Table cell does.+  const finish=save=>{     if(done)return; done=true;-    el.remove();-    document.removeEventListener('mousedown',outside,true);-    document.removeEventListener('keydown',escKey,true);     window.removeEventListener('scroll',onScroll,true);-    window.removeEventListener('resize',cleanup);+    const v=inp.value.trim().replace(/\s+/g,' ');+    closeOwnerCombo(); inp.remove();+    if(save&&v!==cur){ commitCell(r,'owner',v); renderTable(); }   };-  // One commit path for both halves of the control, the list and the field, so a-  // picked name and a typed name are indistinguishable by the time they land.-  const commit=v=>{-    cleanup();-    commitCell(r,'owner',v);-    // The table is the other lens on this edit: re-rendering it refreshes the owner-    // filter and the datalist too, so a brand new name is immediately offered-    // everywhere an owner can be chosen.-    renderTable();-  };-  const outside=ev=>{ if(!ev.target.closest('.owner-list'))cleanup(); };-  // Escape closes it, the same as the status list beside it and the same as every panel-  // in the app. This list never had it: it carried an Escape on its type-in FIELD only,-  // so the key worked if you had clicked into the field and did nothing at all if you-  // had just opened the list and changed your mind, which is the common case. On the-  // document and in the capture phase, which is where the status list puts its own.-  const escKey=ev=>{ if(ev.key==='Escape'){ ev.stopPropagation(); ev.preventDefault(); cleanup(); } };-  // The type-in field needs its mousedown (it has to be focusable and clickable into),-  // and so does the list's own scrollbar, or the bar can be grabbed and will not move.-  el.addEventListener('mousedown',ev=>{-    if(ev.target.closest('.cl-new-inp')||onOwnScrollbar(el,ev))return;-    ev.preventDefault();-  });-  el.addEventListener('click',ev=>{-    const o=ev.target.closest('.cl-opt'); if(!o)return;-    ev.stopPropagation();-    commit(o.dataset.value);-  });-  const inp=el.querySelector('.cl-new-inp');-  inp.addEventListener('keydown',ev=>{-    ev.stopPropagation();                       // the page's own chords stay out of a field-    if(ev.key==='Enter'){ ev.preventDefault(); const v=inp.value.trim(); if(v)commit(v); else cleanup(); }-    else if(ev.key==='Escape'){ ev.preventDefault(); cleanup(); }+  wireOwnerCombo(inp,v=>{ inp.value=v; finish(true); });+  inp.addEventListener('keydown',e=>{+    e.stopPropagation();                         // the page's own chords stay out of a field+    if(e.key==='Enter'){ e.preventDefault(); finish(true); }+    else if(e.key==='Escape'){ e.preventDefault(); finish(false); }   });-  // Clicking away from a half-typed name takes it, rather than throwing the typing-  // away, which is what the table's owner cell does on blur too.-  inp.addEventListener('blur',()=>{ const v=inp.value.trim(); if(!done&&v)commit(v); });-  setTimeout(()=>{-    document.addEventListener('mousedown',outside,true);-    document.addEventListener('keydown',escKey,true);-    window.addEventListener('scroll',onScroll,true);-    window.addEventListener('resize',cleanup);-  },0);+  inp.addEventListener('blur',()=>finish(true));+  inp.addEventListener('mousedown',e=>e.stopPropagation());+  inp.addEventListener('click',e=>e.stopPropagation());+  inp.focus(); inp.select();+  setTimeout(()=>{ if(!done)window.addEventListener('scroll',onScroll,true); },0); } function openCellList(sel,td){   closeCellList();
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.66.0",+  "version": "0.66.1",   "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.66.0",+  "version": "0.66.1",   "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+83−7
@@ -1532,8 +1532,20 @@ async function editorFlows(browser, S) {   await view(page, 'gantt');   await 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('.name-text').dataset.pick = '1'; });   await page.click('.name-text[data-pick="1"]'); await settle(page, 200);-  check('clicking a name in the label column opens the item editor, name selected', await editorShown(page) && await page.$eval('#imName', e => e.value === 'Lane 1 task 2' && document.activeElement === e && e.selectionEnd - e.selectionStart === e.value.length));+  // 0.66.1: a click on the name renames it in place; the item editor does NOT open.+  check('clicking a name in the label column renames it in place, with no editor panel', !(await editorShown(page))+    && await page.evaluate(() => { const a = document.activeElement; return !!a && a.classList.contains('label-edit') && a.value === 'Lane 1 task 2'; }));+  await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');+  await page.keyboard.type('Lane 1 task 2 renamed'); await page.keyboard.press('Enter'); await settle(page, 300);+  let rn = rowOf(await savedPlan(S.url), r => r.id === 'p0t1');+  check('Enter saves the new name', rn.name === 'Lane 1 task 2 renamed' && !(await page.$('.label-edit')), JSON.stringify(rn && rn.name));+  await undo(page); await settle(page, 300);+  rn = rowOf(await savedPlan(S.url), r => r.id === 'p0t1');+  check('and one undo takes it back', rn.name === 'Lane 1 task 2', JSON.stringify(rn && rn.name));+  await page.evaluate(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.querySelector('.name-text')?.textContent === 'Lane 1 task 2'); e.querySelector('.name-text').dataset.pick = '1'; });+  await page.click('.name-text[data-pick="1"]'); await settle(page, 150);   await page.keyboard.press('Escape'); await settle(page, 150);+  check('Escape leaves the name as it was and opens nothing', !(await page.$('.label-edit')) && !(await editorShown(page)));   await view(page, 'table');   await page.click('#tableView tr[data-key="t:finale"] td.c-deps'); await settle(page, 200);   check('the Deps cell opens the item editor with "waits on" focused', await editorShown(page) && await page.evaluate(() => document.activeElement && document.activeElement.id === 'imAddDep'));@@ -1551,18 +1563,82 @@ async function editorFlows(browser, S) {   check('the owner field lists "no owner" and the plan\'s people under it', ol[0] === 'no owner' && ol.includes('Drew') && ol.includes('Ana'), JSON.stringify(ol));   await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');   await page.keyboard.type('an'); await settle(page, 100);-  check('typing filters the list', JSON.stringify(await page.$$eval('.owner-combo .cl-opt', e => e.map(x => x.textContent))) === JSON.stringify(['no owner', 'Ana']));-  await page.click('.owner-combo .cl-opt[data-value="Ana"]'); await settle(page, 100);-  check('a pick fills the field and closes the list', await page.$eval('#imOwner', e => e.value) === 'Ana' && !(await page.$('.owner-combo')));+  check('typing filters the list, the matches first and "no owner" last', JSON.stringify(await page.$$eval('.owner-combo .cl-opt', e => e.map(x => x.textContent))) === JSON.stringify(['Ana', 'no owner']));+  // 0.66.1: the keyboard walks the owner list (the datalist it replaced did; Drew noticed).+  const act = () => page.evaluate(() => { const a = document.querySelector('.owner-combo .cl-opt.active'); return a ? a.textContent : ''; });+  check('nothing is highlighted until a key asks for it', await act() === '');+  await page.keyboard.press('ArrowDown'); await settle(page, 60);+  check('Down highlights the first match', await act() === 'Ana', await act());+  await page.keyboard.press('ArrowDown'); await settle(page, 60);+  await page.keyboard.press('ArrowUp'); await settle(page, 60);+  check('Down and Up walk the list', await act() === 'Ana', await act());+  await page.keyboard.press('Enter'); await settle(page, 120);+  check('Enter takes the highlighted name, closes the list, and does NOT apply the panel', await page.$eval('#imOwner', e => e.value) === 'Ana' && !(await page.$('.owner-combo')) && await editorShown(page));+  await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');+  await page.keyboard.type('Dre'); await settle(page, 80);+  check('a prefix of a known name lists it, unhighlighted', JSON.stringify(await page.$$eval('.owner-combo .cl-opt', e => e.map(x => x.textContent))) === JSON.stringify(['Drew', 'no owner']) && await act() === '');+  await page.keyboard.press('Enter'); await settle(page, 400);+  let o3 = rowOf(await savedPlan(S.url), r => r.id === 'p0t2');+  check('Enter with nothing highlighted keeps what was typed (a NEW name) and applies the panel', o3.owner === 'Dre' && !(await editorShown(page)), JSON.stringify(o3 && o3.owner));+  await undo(page); await settle(page, 300);+  await openItemEditor(page, 'Lane 1 task 3');+  await page.click('#imOwner'); await settle(page, 120);+  await page.keyboard.press('Escape'); await settle(page, 80);+  check('Escape closes the list and leaves the panel open', !(await page.$('.owner-combo')) && await editorShown(page));+  await page.keyboard.press('ArrowDown'); await settle(page, 80);+  check('Down on the closed field opens the list, highlighted', !!(await page.$('.owner-combo')) && await act() === 'no owner', await act());+  await page.keyboard.press('Escape'); await settle(page, 60);+  // The pick lists: the arrows walk them and the list stays open; Enter closes it.+  await page.focus('#imStatus'); await settle(page, 60);+  const st0 = await page.$eval('#imStatus', e => e.value);+  await page.keyboard.press('ArrowDown'); await settle(page, 80);+  const stOpen = await page.evaluate(() => { const l = document.querySelector('.cell-list:not(.owner-list)'); const on = l && l.querySelector('.cl-opt.on'); return l ? (on ? on.dataset.value : '?') : ''; });+  check('Down on the status select opens the list and moves the value, and the list follows', stOpen && stOpen !== st0 && stOpen === await page.$eval('#imStatus', e => e.value), `${st0} -> ${stOpen}`);+  await page.keyboard.press('ArrowDown'); await settle(page, 80);+  check('a second Down keeps the list open (it used to close on the first)', !!(await page.$('.cell-list:not(.owner-list)')));+  await page.keyboard.press('Enter'); await settle(page, 100);+  check('Enter closes the list and does not apply the panel', !(await page.$('.cell-list:not(.owner-list)')) && await editorShown(page));+  // "waits on": the arrows walk it WITHOUT adding a dependency for every task passed.+  const ins = () => page.$$eval('#imDepList .dep-rm[data-dir="in"]', e => e.length);+  const in0 = await ins();+  await page.focus('#imAddDep'); await settle(page, 60);+  for (let k = 0; k < 3; k++) { await page.keyboard.press('ArrowDown'); await settle(page, 60); }+  check('arrowing through "waits on" adds nothing', await ins() === in0, String(await ins()));+  await page.keyboard.press('Enter'); await settle(page, 120);+  check('Enter adds the highlighted task, once', await ins() === in0 + 1 && await editorShown(page), String(await ins()));   await page.click('#imCancel'); await settle(page, 150);   // Let any pending save land first: a save re-renders the table, which (as for every cell   // editor) ends an edit that happens to be open at that moment.   await view(page, 'table'); await settle(page, 1000);   await page.click('#tableView tr[data-key="t:p0t2"] td[data-field="owner"]'); await settle(page, 150);-  check('the Table\'s Owner cell opens the same owner list', !!(await page.$('.owner-combo .cl-opt[data-value=""]')),-    JSON.stringify(await page.evaluate(() => ({ ae: document.activeElement && (document.activeElement.className + '|' + document.activeElement.tagName), cellEdit: !!document.querySelector('.cell-edit'), combo: !!document.querySelector('.owner-combo'), row: !!document.querySelector('#tableView tr[data-key="t:p0t2"]') }))));-  await page.keyboard.press('Escape'); await page.keyboard.press('Escape'); await settle(page, 150);+  check('the Table\'s Owner cell opens the same owner list', !!(await page.$('.owner-combo .cl-opt[data-value=""]')));+  await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');+  await page.keyboard.type('jo'); await settle(page, 80);+  await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); await settle(page, 400);+  o3 = rowOf(await savedPlan(S.url), r => r.id === 'p0t2');+  check('in the Table: type, Down, Enter takes the name and saves the cell', o3.owner === 'John' && !(await page.$('.cell-edit')), JSON.stringify(o3 && o3.owner));+  await undo(page); await settle(page, 300);+  await page.click('#tableView tr[data-key="t:p0t2"] td[data-field="owner"]'); await settle(page, 150);+  await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');+  await page.keyboard.type('dr'); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Tab'); await settle(page, 400);+  o3 = rowOf(await savedPlan(S.url), r => r.id === 'p0t2');+  check('Tab takes the highlighted name, saves, and moves to the next cell', o3.owner === 'Drew' && await page.evaluate(() => !!document.activeElement && !!document.activeElement.closest('td') && document.activeElement.closest('td').dataset.field !== 'owner'), JSON.stringify(o3 && o3.owner));+  await page.keyboard.press('Escape'); await settle(page, 150);+  await undo(page); await settle(page, 300);+  // The owner pill on the chart opens the SAME picker, so it walks the same way.   await view(page, 'gantt');+  await 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('.owner-pill').dataset.pick = '1'; });+  await page.click('.owner-pill[data-pick="1"]'); await settle(page, 150);+  check('the owner pill opens the owner field over it, holding the owner, with the list', await page.evaluate(() => { const a = document.activeElement; return !!a && a.classList.contains('pill-owner-inp') && a.value === 'John'; }) && !!(await page.$('.owner-combo')));+  await page.keyboard.type('an'); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); await settle(page, 400);+  const p1 = rowOf(await savedPlan(S.url), r => r.id === 'p0t1');+  check('type, Down, Enter on the pill sets the owner', p1.owner === 'Ana' && !(await page.$('.pill-owner-inp')), JSON.stringify(p1 && p1.owner));+  await undo(page); await settle(page, 300);+  check('and one undo puts it back', rowOf(await savedPlan(S.url), r => r.id === 'p0t1').owner === 'John');+  await page.evaluate(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.querySelector('.name-text')?.textContent === 'Lane 1 task 2'); e.querySelector('.owner-pill').dataset.pick = '1'; });+  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');    section = 'editor: item';   const t1 = rowOf(plan0, r => r.name === 'Lane 1 task 1');