← Commit history

Publish 0.65.0

Drew Owens ·98f62e4814 · ·parent 6cf766a
6 files changed +330−139
SKILL.md+21−4
@@ -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** to open the date editor with start/end pickers and dependency management. It is a compact 256px square (`--ed-sq`, one token for both dimensions): the row's name across the top, the two dates, the note, the dependency list and the buttons, at desktop density, so it covers as little of the chart as it can. The dependency list is the only part that flexes and the only part that scrolls; the buttons are pinned to the bottom edge. The milestone panel and the batch panel take the same compact metrics; the full **Edit item** panel, which is filled in rather than glanced at, keeps the full ones+- **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 - **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@@ -278,8 +278,8 @@ rather than a category. - **Dynamic date range** computed from the data every render - **Project start** in the header, shown as an exact date (`21-Sep-2026`) at every grain since 0.57.0; it used to show only the month at any grain but Days. At the coarser grains its picker still moves the plan by whole months and keeps the day of the month, clamped to the month's length (the 31st moved to February lands on the 28th or 29th, not on 3 March). Changing it moves the WHOLE plan: every task, milestone and phase shifts by the same number of days, so durations and gaps are preserved. Revert puts it back to the data file's own start. This is a real edit, recorded and undoable, not a view setting - **Cell size** picker: Days, Weeks, Months, Quarters, Years. Each grain has its own minimum cell width sized to the widest label it draws, so a fine grain scrolls rather than squashing while a coarse one stretches to fill the pane. Day and week positions are exact; month, quarter and year approximate within a month-- **Bar notes** put a short label on the bar body. Double-click a bar to type one, or use the note field in the editor-- **Milestones are editable too**: drag one sideways to move its date, or click it for a panel with its name, date and description. Line style is the default: the tag on the dashed rule is the handle, and the milestone's grid row is hidden since the rule already carries it. Diamond style keeps the row. Long titles wrap, and any tag that would still collide with its neighbour drops to a second row, so tags never overlap+- **Bar labels** put a short label on the bar body (`note`, e.g. `6m`), set in the item editor's "label on the bar" field. The double-click that used to open a one-field label editor on the bar is gone (0.65.0): the first click of it already opens the item editor+- **Milestones are editable too**: drag one sideways to move its date, or click it for the item editor, which hides the fields a milestone does not have (start, effort, bar label, dependencies) - **Dynamic today marker** shows the current date, positioned with the same grain-aware mapping as the bars, so it is correct at every cell size - **Resizable name column**: drag the divider, or double-click it to fit the longest name. Width persists in `localStorage` - **Table columns are a fixed grid.** Default widths are per column type and identical for every plan (Name 23%, Notes 22%, Provenance 14%, Phase 10%, Owner 8%, Status 8%, Start 7.5%, Deadline 7.5%, delete 30px). Nothing in the body can change them: clicking a cell, opening or cancelling an editor, hovering, sorting, filtering, adding or deleting a row all leave every column exactly where it was. Widths change only when the user drags a header grip or double-clicks one to fit the column to its content, and those persist in `prefs.tableCols`, after which the table is pixel-driven@@ -290,6 +290,23 @@ View preferences (theme, milestone style, cell size, today marker, name column w `localStorage` per browser. They are a view concern, so they are not written to the data file. The project start is NOT a view preference: it edits real dates. +## Data entry: one rule per kind of field (0.65.0)++An audit of every place a value is typed or picked, so the same kind of value is always+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 |+| 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 |+| Sections and the tag list | Edited in place on the Setup page: click a name to rename, drag a row to move it |+| Many rows at once | The batch panel (Ctrl+click): dates shift on Apply; tags and effort act at once, one undo each |+ ## The table view  The header carries a **Gantt / Table** toggle. The table is the same items in a@@ -315,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 single click on a chart label edits the name inline; a double-click opens the full item editor. Dragging a task across a group header changes its phase.+**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.  **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+37
@@ -3,6 +3,43 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-05 (0.65.0, one item editor, one picker per kind of field)++Drew: "clicking on a task bar or milestone should just open the Edit All Fields box ...+Please audit the app for variations of each type of data entry and ensure we are+maximizing consistency across the entire app as much as possible."++The audit found the same row edited FOUR ways: a bar click opened a small panel (dates,+a bar label and dependencies, whose adds and removes landed at once and ignored Cancel);+a milestone click a second small panel (name, date, "description"); the pencil the full+editor; a click on the name a one-line rename in place; and a double-click on a bar a+one-field label editor on the bar. Owners were a pop-up list on the chart pill and the+browser's datalist everywhere else. Picks were the app's own list in the Table and on the+status pill and the browser's popup in the editor and the New panel.++* **One item editor.** A click on a bar, a milestone, a name in the label column, the+  pencil, a double-click and the Table's Deps cell all open the same panel, beside what+  was clicked. The bar label and the dependencies moved into it (hidden for a milestone,+  like its start and effort). The dependencies are a draft now, like every other field:+  "waits on" adds, an x removes a "needs" line or a "blocks" line, and nothing lands+  until Apply, as one undo with any other row a "blocks" removal touched. A task that+  would close a loop is not offered. The two small panels and the bar double-click+  editor are gone.+* **One list for picks.** Section, status, type and "waits on" in the editor and the New+  panel open the app's own list, the one the Table and the status pill use, above the+  panel. The select underneath is still the control, so the keyboard is its own.+* **One owner picker.** A field with "no owner" and the plan's people listed under it,+  filtered as you type, in the editor, the New panel and the Table's Owner cell; the+  pill's list was already this. The list follows its field when the page scrolls, since+  focusing a Table cell scrolls the table a frame after the list opens.+* Left as they are, on purpose: dates stay the browser's date field (the header's+  project start keeps its own picker, since it moves the whole plan); in-place edits+  (Table cells, pills, Setup) commit on Enter or a click away while panels commit on+  Apply, which is the difference between editing one value and editing a row; lane+  header names are edited in place, as sections are on Setup.++The SKILL has a "Data entry" table stating the one rule for each kind of field.+ ## Build notes, 2026-10-05 (0.64.2, the version on every page)  Drew: "show version number on all pages". The version rode beside the Duration and
gantt.html+187−94
@@ -910,6 +910,11 @@ body.ed-dragging { user-select: none; -webkit-user-select: none; } .dep-list li { display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: var(--text2); padding: 3px 0; } .dep-list .dep-rm { color: var(--text2); cursor: pointer; font-size: 13px; padding: 0 4px; border-radius: 4px; transition: color 0.15s; } .dep-list .dep-rm:hover { color: #f85149; }+.dep-list .dep-dir { font-family: 'JetBrains Mono', monospace; font-size: 9px; margin-right: 6px; min-width: 48px; flex-shrink: 0; color: var(--text2); }+.dep-list .dep-dir.in { color: var(--accent); }+.dep-list .dep-nm { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.dep-list .dep-none { color: var(--text3); font-size: 11px; }+.editor #imDepsWrap .dep-list { max-height: 132px; overflow-y: auto; } .batch-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 600; color: var(--accent); margin-bottom: 10px; } .batch-hint { font-size: 11px; color: var(--text2); margin-bottom: 8px; } .batch-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }@@ -1464,7 +1469,7 @@ body.col-sizing { cursor: col-resize; user-select: none; }    Tab to close itself, so leaving a dropdown cost a keypress that leaving a text    field did not. Fixed position and appended to the body, because the cells clip    their own overflow under the fixed table layout. */-.cell-list { position: fixed; z-index: 60; min-width: 130px; max-height: 232px; overflow-y: auto;+.cell-list { position: fixed; z-index: 100001; min-width: 130px; max-height: 232px; overflow-y: auto;   background: var(--surface); border: 1px solid var(--border); border-radius: 7px; padding: 4px;   box-shadow: 0 8px 22px rgba(0,0,0,0.34); font-family: 'Satoshi', sans-serif; font-size: 12px; } .cell-list .cl-opt { padding: 5px 9px; border-radius: 5px; color: var(--text-body); cursor: pointer; white-space: nowrap; }@@ -6422,10 +6427,15 @@ 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.   const ntEl=e.target.closest('.name-text');   if(ntEl){-    if(selected.size)clearSelection();-    editLabelInline(r,caretIndexAt(ntEl,e.clientX,r.name||''));+    e.stopPropagation();+    closeLabelEditors(true);+    selectOnly(r);+    openItemModal(r);     return;   }   // The gap: select the row. selUid is null for anything that cannot be selected, so@@ -6667,7 +6677,8 @@ bars.addEventListener('click',e=>{     // and Alt+Up, Alt+Down and the table's cursor all read "nothing chosen". selectOnly     // hands the same row to the table, so the two lenses agree about what is current.     if(mr)selectOnly(mr); else clearSelection();-    openMilestoneEditor(msHit.dataset.msuid); e.stopPropagation(); return;+    { const mrow=rowByUid(msHit.dataset.msuid); if(mrow)openItemModal(mrow,undefined,{anchor:msHit.getBoundingClientRect()}); }+    e.stopPropagation(); return;   }   const bar=e.target.closest('.bar[data-id]');   if(bar&&!e.target.classList.contains('drag-handle')){@@ -6686,7 +6697,7 @@ bars.addEventListener('click',e=>{     const plainClick=!(e.ctrlKey||e.metaKey||e.shiftKey);     const panelUp=editorEl.classList.contains('show')&&selected.size===1&&soleSelectedRow()===br;     if(plainClick&&!panelUp)selectSole(br); else doSelect(br&&br.uid, e);-    if(selected.size===1&&soleSelectedRow()===br)openSingleEditor(id);+    if(selected.size===1&&soleSelectedRow()===br)openItemModal(br,undefined,{anchor:bar.getBoundingClientRect()});     else if(selected.size>1)openBatchEditor();     else closeEditor();     e.stopPropagation();@@ -7753,74 +7764,8 @@ function openDelete(row,i,held){ }  // ── Milestone editor ──-function openMilestoneEditor(uid){-  const r=rowByUid(uid);if(!r||r.type!=='milestone')return;-  closeEditor();-  edSubject=r;-  editorEl.innerHTML=`<div class="drag-bar"></div>-    <div class="de-row"><div style="flex:1"><label>milestone</label>-      <input type="text" id="meName" value="${esc(r.name||'')}"></div></div>-    <div class="de-row"><div><label>date</label><input type="date" id="meDate" value="${r.date}"></div></div>-    <div class="de-note-row"><label>description <span class="de-hint">shown on hover</span></label>-      <input type="text" id="meDesc" value="${esc(r.description||'')}"></div>-    <div class="de-actions"><button class="de-btn primary" id="meApply">Apply</button>-      <button class="de-btn secondary" id="meClose">Cancel</button>-      <button class="de-btn danger" id="meDelete" title="Delete this milestone from the plan. Undoable with Ctrl+Z">Delete</button></div>`;-  const x=r._msHit?r._msHit.getBoundingClientRect():{left:window.innerWidth/2,bottom:200};-  showEditor(x.left-150,x.bottom+8,'compact');-  $('meApply').addEventListener('click',()=>{-    snapshotMs(r);-    const nd=$('meDate').value;-    if(nd)r.date=nd;-    r.name=$('meName').value||r.name;-    r.description=$('meDesc').value;-    if(r._lDiv)r._lDiv.textContent=r.name;-    if(r._msHit){r._msHit.dataset.title=r.name;r._msHit.dataset.body=r.description||'';}-    if(r._msLine){const cap=r._msLine.querySelector('.ms-cap');if(cap)cap.textContent=r.name.replace(/^M(\d+):\s*/,'M$1 ');}-    updateMilestone(r);closeEditor();scheduleSave('milestone-editor');-  });-  $('meClose').addEventListener('click',closeEditor);-  $('meDelete').addEventListener('click',()=>deleteFromPanel(r));-}--// ── Single editor ──-function openSingleEditor(id,anchor){-  const r=items.find(d=>d.id===id);if(!r)return;-  edSubject=r;-  const allTasks=items.filter(d=>d.id&&d.id!==id),deps=r.deps||[];-  editorEl.innerHTML=`<div class="drag-bar"></div><div class="de-title" id="deTitle"></div><div class="de-row"><div><label>start</label><input type="date" id="deStart" value="${r.start}"></div><div><label>end</label><input type="date" id="deEnd" value="${r.end}"></div></div><div class="de-note-row"><label>note <span class="de-hint">shown on the bar</span></label><input type="text" id="deNote" maxlength="24" value="${esc(r.note||'')}" placeholder="e.g. 6m"></div><div class="de-dep-section"><label>dependencies</label><ul class="dep-list" id="deDepList"></ul><select id="deAddDep" style="margin-top:4px;"><option value="">+ add dependency...</option>${allTasks.filter(t=>!deps.includes(t.id)).map(t=>`<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('')}</select></div><div class="de-actions"><button class="de-btn primary" id="deApply">Apply</button><button class="de-btn secondary" id="deClose">Cancel</button><button class="de-btn danger" id="deDelete" title="Delete this task from the plan. Undoable with Ctrl+Z">Delete</button></div>`;-  setEdTitle(r);-  renderDepList(id);-  // Anchored to the bar in the chart. In the table the chart is display:none and-  // every bar rect is zero, so the caller passes the cell it came from instead.-  const rect=anchor||barEls[id].bar.getBoundingClientRect();-  // Auto-placement: beside the bar, flipped to its left when the panel would not fit-  // on the right. placeEditor does the final clamp against both edges, and overrides-  // all of this the moment the user has dragged a panel somewhere they prefer.-  const sq=edSquareSide();-  let edX=rect.right+8;-  if(edX+sq>window.innerWidth) edX=rect.left-sq-8;-  showEditor(edX,rect.top,'square');-  $('deAddDep').addEventListener('change',function(){if(this.value){snapshot(id);if(!r.deps)r.deps=[];r.deps.push(this.value);renderDepList(id);drawDeps();scheduleSave('editor');const o=this.querySelector(`option[value="${r.deps[r.deps.length-1]}"]`);if(o)o.remove();this.value='';}});-  $('deApply').addEventListener('click',()=>{snapshot(id);const ns=$('deStart').value,ne=$('deEnd').value;if(ns)r.start=ns;if(ne)r.end=ne;r.note=$('deNote').value;updateBar(id);closeEditor();scheduleSave('editor');});-  $('deClose').addEventListener('click',closeEditor);-  $('deDelete').addEventListener('click',()=>deleteFromPanel(r));-}--function renderDepList(id){-  const r=items.find(d=>d.id===id),list=$('deDepList');if(!list)return;-  const incoming=(r.deps||[]).map(depId=>{const dep=items.find(d=>d.id===depId);return{depId,name:dep?dep.name:depId,dir:'in'};});-  const outgoing=items.filter(d=>d.deps&&d.deps.includes(id)).map(d=>({depId:d.id,name:d.name,dir:'out'}));-  const all=[...incoming,...outgoing];-  list.innerHTML=all.map(d=>`<li><span style="font-family:'JetBrains Mono',monospace;font-size:9px;color:${d.dir==='in'?'var(--accent)':'var(--text2)'};margin-right:4px;min-width:48px">${d.dir==='in'?'← needs':'→ blocks'}</span><span style="flex:1;color:${d.dir==='in'?'var(--text)':'var(--text2)'}">${esc(d.name)}</span><span class="dep-rm" data-dep="${esc(d.depId)}" data-dir="${esc(d.dir)}">×</span></li>`).join('')||'<li style="color:var(--text2)">none</li>';-  list.querySelectorAll('.dep-rm').forEach(btn=>{btn.addEventListener('click',()=>{-    const depId=btn.dataset.dep, dir=btn.dataset.dir;-    if(dir==='in'){snapshot(id);r.deps=r.deps.filter(d=>d!==depId);}-    else{const blocker=items.find(d=>d.id===depId);if(blocker){snapshot(depId);blocker.deps=blocker.deps.filter(d=>d!==id);}}-    renderDepList(id);drawDeps();scheduleSave('editor');-  });});-}-+// The small milestone panel (name, date, description) is gone (0.65.0): a milestone+// click opens the item editor, which has every one of those fields. // ── Batch editor (relative adjustments) ── function daysBetween(a,b){const[ay,am,ad]=a.split('-').map(Number),[by,bm,bd]=b.split('-').map(Number);return Math.round((new Date(by,bm-1,bd)-new Date(ay,am-1,ad))/86400000);} @@ -8010,7 +7955,9 @@ function setEditorKeys(fn){   edPanelKeys=fn||null;   if(edPanelKeys)editorEl.addEventListener('keydown',edPanelKeys); }-function closeEditor(){edSubject=null;setEditorKeys(null);editorEl.classList.remove('show','square','compact');editorEl.innerHTML='';}+// Closes the lists the PANEL opened, and only those: a Table cell's list is opened by the+// 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');}  // ── Drag (edge-resize, body-move horizontal, reorder vertical) ──@@ -8675,16 +8622,10 @@ function editNoteInline(id){   inp.addEventListener('blur',()=>commit(true));   be.bar.appendChild(inp);inp.focus();inp.select(); }-function wireBarDblclick(){-document.addEventListener('dblclick',e=>{-  if(depMode)return;-  const bar=e.target.closest('.bar');-  if(!bar||!bar.dataset.id)return;-  e.preventDefault();e.stopPropagation();-  closeEditor();-  editNoteInline(bar.dataset.id);-});-}+// A double-click on a bar used to open a one-field label editor ON the bar. The label is+// a field of the item editor now (0.65.0), which the first click of that double-click has+// already opened, so the second click does nothing more.+function wireBarDblclick(){}  // ── Tooltip ── let tipTimer=null;@@ -9748,7 +9689,15 @@ function wireTagBox(box,initial,kind,onChange){ // Everything about one row in one panel: the same fields the create panel offers, // on an existing item. Reached by double-clicking a label, which upgrades from // the inline name editor and carries whatever was already typed.-function openItemModal(r,carriedName){+// THE item editor (0.65.0): one panel for a task or a milestone, however it is reached:+// a click on the bar or the milestone, on the name in the label column, on the row's Deps+// cell, the pencil, or a double-click. Through 0.64 a bar click opened a small dates-and-+// dependencies panel, a milestone click a name-date-description panel, and only the pencil+// this one, so the same row was edited three different ways. The bar label and the+// dependencies those small panels held live here now. `opts.anchor` places it beside the+// thing clicked; `opts.focus` is 'deps' when the Deps cell opened it.+function openItemModal(r,carriedName,opts){+  opts=opts||{};   closeEditor();   edSubject=r;   const ms=r.type==='milestone';@@ -9786,7 +9735,7 @@ function openItemModal(r,carriedName){     </div>     <div class="de-note-row"><label>name <span class="de-hint">required</span></label><input type="text" id="imName" placeholder="What is it?"></div>     <div class="de-row" style="margin-top:8px">-      <div style="flex:1"><label>owner</label><input type="text" id="imOwner" list="ownerList" placeholder="one name"></div>+      <div style="flex:1"><label>owner</label><input type="text" id="imOwner" autocomplete="off" placeholder="one name"></div>       <div style="flex:1"><label>status</label><select id="imStatus"><option value="">none</option>${STATUS_ORDER.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('')}</select></div>     </div>     <div class="de-row">@@ -9797,6 +9746,9 @@ function openItemModal(r,carriedName){     <div class="de-note-row"><label>notes</label><input type="text" id="imDesc" placeholder="Shown on hover and in the table"></div>     <div class="de-note-row"><label>provenance <span class="de-hint">where this came from</span></label><input type="text" id="imProv"></div>     <div class="de-note-row"><label>tags <span class="de-hint">Enter or a comma adds one</span></label>${tagBoxHtml('imTags','Tags on this row')}</div>+    <div class="de-note-row" id="imNoteWrap"><label>label on the bar <span class="de-hint">a few characters, e.g. 6m</span></label><input type="text" id="imNote" maxlength="24" placeholder="e.g. 6m"></div>+    <div class="de-dep-section" id="imDepsWrap"><label>dependencies</label><ul class="dep-list" id="imDepList"></ul>+      <select id="imAddDep" style="margin-top:4px"></select></div>     <div class="de-actions"><button class="de-btn primary" id="imApply">Apply</button>       <button class="de-btn secondary" id="imCancel">Cancel</button>       <button class="de-btn danger" id="imDelete" title="Delete this row from the plan. Undoable with Ctrl+Z">Delete</button></div>@@ -9816,6 +9768,39 @@ function openItemModal(r,carriedName){   $('imOwner').value=r.owner||'';   $('imEffWrap').style.display=ms?'none':'';   $('imEffort').value=effortOf(r)===null?'':String(effortOf(r));+  // A milestone has no bar to label and is never a dependency endpoint (a dep names a+  // task id), so both are hidden for it, the way its start field is.+  $('imNoteWrap').style.display=ms?'none':'';+  $('imDepsWrap').style.display=ms?'none':'';+  $('imNote').value=r.note||'';+  // The dependencies are a DRAFT, like every other field here: nothing lands until Apply,+  // and Cancel or Escape leaves the plan as it was. (The small bar panel this replaces+  // wrote each add and remove at once, the one field in the app that ignored Cancel.)+  // `needs` is what this task waits on; a "blocks" line is another task waiting on this+  // one, and its x takes this task out of THAT task's list on Apply.+  let dNeeds=ms?[]:[...(r.deps||[])];+  const dUnblock=new Set();+  const renderDeps=()=>{+    if(ms)return;+    const list=$('imDepList'), add=$('imAddDep'); if(!list||!add)return;+    const blocks=items.filter(d=>d.id&&d.deps&&d.deps.includes(r.id)&&!dUnblock.has(d.id));+    const li=(dir,id,name)=>`<li><span class="dep-dir ${dir}">${dir==='in'?'← needs':'→ blocks'}</span><span class="dep-nm" title="${esc(name)}">${esc(name)}</span>`+      +`<span class="dep-rm" role="button" tabindex="0" data-dep="${esc(id)}" data-dir="${dir}" title="${dir==='in'?'This task stops waiting on it':'It stops waiting on this task'}. Lands on Apply">×</span></li>`;+    list.innerHTML=dNeeds.map(id=>li('in',id,depName(id))).join('')+blocks.map(b=>li('out',b.id,b.name||b.id)).join('')+      ||'<li class="dep-none">No dependencies.</li>';+    // Offered: every other task this one does not wait on yet, minus any that would close+    // a loop (one that already waits on this task, directly or through a chain).+    const cand=items.filter(t=>t.id&&t.id!==r.id&&t.type!=='milestone'&&!dNeeds.includes(t.id)&&!depPathTo(t.id,r.id));+    add.innerHTML=`<option value="">+ waits on...</option>`+cand.map(t=>`<option value="${esc(t.id)}">${esc(t.name||t.id)}</option>`).join('');+    add.value='';+    list.querySelectorAll('.dep-rm').forEach(b=>{+      const go=()=>{ if(b.dataset.dir==='in')dNeeds=dNeeds.filter(d=>d!==b.dataset.dep); else dUnblock.add(b.dataset.dep); renderDeps(); };+      b.addEventListener('click',e=>{ e.stopPropagation(); go(); });+      b.addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); e.stopPropagation(); go(); } });+    });+  };+  renderDeps();+  if(!ms)$('imAddDep').addEventListener('change',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||'';@@ -9823,9 +9808,16 @@ function openItemModal(r,carriedName){   const tagBox=wireTagBox($('imTags'),ownTags(r),'im');   const secT=phaseTagsOf(r);   if(secT.length)$('imTags').title='Its section also gives it: '+secT.join(', ')+'. Those are edited on the Setup page.';-  const rect=(r._lDiv||document.body).getBoundingClientRect();-  showEditor(rect.right+12,rect.top,false);-  $('imName').focus();$('imName').select();+  // Beside what was clicked: the bar, the milestone or the Deps cell, flipped to its left+  // when the right has no room; otherwise beside the row's label. placeEditor clamps it.+  const rect=opts.anchor||(r._lDiv||document.body).getBoundingClientRect();+  const PW=440;+  let px=rect.right+12; if(opts.anchor&&px+PW>window.innerWidth)px=Math.max(8,rect.left-PW-12);+  showEditor(px,rect.top,false);+  ['imPhase','imStatus','imAddDep'].forEach(id=>{ const el=$(id); if(el)wireAppSelect(el); });+  wireOwnerCombo($('imOwner'));+  if(opts.focus==='deps'&&!ms)$('imAddDep').focus();+  else{ $('imName').focus();$('imName').select(); }   setEditorKeys(e=>{e.stopPropagation();     if(e.key==='Enter'&&e.target.tagName!=='BUTTON'){e.preventDefault();$('imApply').click();}     else if(e.key==='Escape'){e.preventDefault();closeEditor();}});@@ -9839,7 +9831,10 @@ function openItemModal(r,carriedName){     if(Number.isNaN(eff)){const er=$('imErr');er.textContent='Effort is hours, like 16, 2d or 1w, or empty for not estimated.';er.style.display='';$('imEffort').focus();return;}     const np=parseInt($('imPhase').value,10);     const moves=!!phases[np]&&np!==r.phase;-    if(ms)snapshotMs(r); else snapshot(r.id);+    // The rows a "blocks" x changes are snapshotted WITH this one, so the whole Apply is+    // still one Ctrl+Z (snapshotMany makes one entry of several rows).+    const unblocked=ms?[]:[...dUnblock].map(id=>items.find(d=>d.id===id)).filter(Boolean);+    if(ms)snapshotMs(r); else snapshotMany([r.id,...unblocked.map(b=>b.id)]);     // ONE Ctrl+Z takes the whole Apply back, the section included. The field snapshot     // does not carry the lane or the row's place in the plan, and moveRowToPhase records     // nothing of its own, so until 0.57.1 an undo restored every field and left the row@@ -9862,6 +9857,12 @@ function openItemModal(r,carriedName){     setOpt(r,'status',STATUS_ORDER.includes($('imStatus').value)?$('imStatus').value:'');     setOpt(r,'description',$('imDesc').value.trim());     if(!ms)setEffort(r,eff);+    let depsMoved=false;+    if(!ms){+      r.note=$('imNote').value.trim();+      if(JSON.stringify(dNeeds)!==JSON.stringify(r.deps||[])){ r.deps=dNeeds.slice(); depsMoved=true; }+      unblocked.forEach(b=>{ b.deps=(b.deps||[]).filter(d=>d!==r.id); depsMoved=true; });+    }     const pl=$('imProv').value.trim();     if(!pl)delete r.provenance;     else r.provenance=p.url?{label:pl,url:p.url}:pl;@@ -9873,6 +9874,7 @@ function openItemModal(r,carriedName){     if(ms){refreshMsChrome(r);updateMilestone(r);} else {updateBar(r.id);refreshLabelRow(r);}     if(phases[np]&&np!==r.phase)moveRowToPhase(r,np);     if(tagsMoved)afterTagsChange();+    if(depsMoved)drawDeps();     applyCollapse();refreshChartGeometry();renderTable();     closeEditor();     scheduleSave('item-editor');@@ -9933,7 +9935,7 @@ function openCreateEditor(){     </div>     <div class="de-note-row"><label>name <span class="de-hint">required</span></label><input type="text" id="ncName" placeholder="What is it?"></div>     <div class="de-row" style="margin-top:8px">-      <div style="flex:1"><label>owner</label><input type="text" id="ncOwner" list="ownerList" placeholder="one name" value="${esc(ownerVal)}"></div>+      <div style="flex:1"><label>owner</label><input type="text" id="ncOwner" autocomplete="off" placeholder="one name" value="${esc(ownerVal)}"></div>       <div style="flex:1"><label>status</label><select id="ncStatus">${STATUS_ORDER.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('')}</select></div>     </div>     <div class="de-row">@@ -9967,6 +9969,8 @@ function openCreateEditor(){   // Carried like every other field but the name: six rows for the same "long-lead" work   // are six names typed and nothing else.   const tagBox=wireTagBox($('ncTags'),memo.tags||[],'nc');+  ['ncType','ncPhase','ncStatus'].forEach(id=>{ const el=$(id); if(el)wireAppSelect(el); });+  wireOwnerCombo($('ncOwner'));   $('ncName').focus();   $('ncCancel').addEventListener('click',closeEditor);   setEditorKeys(e=>{e.stopPropagation();@@ -11357,6 +11361,93 @@ function openStatusPill(r,pillEl){   setTimeout(()=>{ document.addEventListener('mousedown',outside,true); document.addEventListener('keydown',escKey,true); },0); } +// ── One picker per kind of field (0.65.0) ──+//+// THE LIST. Every select a person picks from (phase, status, type, "waits on" in the item+// editor and the New panel) opens the app's own list, the one the Table's cells and the+// status pill already used, instead of the browser's popup. Before this the same choice+// looked like two different controls depending on where you made it. The select is still+// the real control underneath: the keyboard is its own, and a pick writes its value and+// fires its change event, so nothing that reads the select had to change.+function wireAppSelect(sel){+  if(!sel||sel._appList)return; sel._appList=true;+  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);+  });+  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);+}+// 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.+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('');+}+function closeOwnerCombo(){+  if(!ownerCombo)return;+  const c=ownerCombo; ownerCombo=null;+  c.el.remove();+  window.removeEventListener('scroll',c.onScroll,true);+  window.removeEventListener('resize',closeOwnerCombo);+}+function wireOwnerCombo(inp,onPick){+  if(!inp||inp._ownerWired)return; inp._ownerWired=true;+  const place=el=>{+    const r=inp.getBoundingClientRect();+    el.style.minWidth=Math.max(172,Math.round(r.width))+'px';+    el.style.left=Math.max(4,Math.min(r.left,window.innerWidth-el.offsetWidth-6))+'px';+    const below=window.innerHeight-r.bottom;+    el.style.top=(below>el.offsetHeight+8?r.bottom+2:Math.max(4,r.top-el.offsetHeight-2))+'px';+  };+  const open=()=>{+    if(!inp.isConnected)return;+    const cur=(inp.value||'').trim();+    if(!ownerCombo||ownerCombo.inp!==inp){+      closeOwnerCombo(); closeCellList();+      const el=document.createElement('div'); el.className='cell-list owner-list owner-combo';+      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);+      });+      // 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('resize',closeOwnerCombo);+      ownerCombo={el,inp,onScroll};+    }+    // 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);+    place(ownerCombo.el);+  };+  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);+  if(document.activeElement===inp)open();+}+ // The owner pill in the label column, editable in place. Same deal as the status pill // above, and the same commit path: commitCell(r,'owner',v), which is the SAME call the // table's Owner cell makes, so a name set here and a name typed there are one kind of@@ -11501,7 +11592,7 @@ function startCellEdit(td,caret){     el.placeholder='hours, 2d, 1w';el.title=EFFORT_HELP;     el.value=effortOf(r)===null?'':String(effortOf(r));   } else if(field==='owner'){-    el=document.createElement('input');el.type='text';el.setAttribute('list','ownerList');+    el=document.createElement('input');el.type='text';el.autocomplete='off';el._ownerCombo=true;     el.placeholder='one name';el.value=r.owner||'';   } else if(field==='tags'){     // The same chip box the item editor uses, laid over the cell; `el` is its input, so@@ -11580,7 +11671,7 @@ function startCellEdit(td,caret){   let done=false;   const finish=save=>{     if(done)return;done=true;tblEditing=null;-    closeCellList();+    closeCellList(); closeOwnerCombo();     // Blur before the re-render so focus lands on the document rather than being     // orphaned on a node that is about to be thrown away.     try{ el.blur(); }catch(e){}@@ -11617,6 +11708,8 @@ function startCellEdit(td,caret){     });   el.addEventListener('blur',()=>finish(true));   if(el.tagName==='SELECT')el.addEventListener('change',()=>finish(true));+  // The Owner cell takes the one owner picker (0.65.0): a pick commits the cell.+  if(el._ownerCombo)wireOwnerCombo(el,v=>{ el.value=v; finish(true); }); }  function commitCell(r,field,v){@@ -11720,7 +11813,7 @@ tableBody.addEventListener('click',e=>{     // escapes it by being in that handler's allowlist; this one stops here instead.     e.stopPropagation();     const tr=dc.closest('tr'); const r=rowItem(tr&&tr.dataset.key);-    if(r&&r.id&&barEls[r.id]){ setFocusedRow(tr.dataset.key); openSingleEditor(r.id,dc.getBoundingClientRect()); }+    if(r&&r.id&&barEls[r.id]){ setFocusedRow(tr.dataset.key); openItemModal(r,undefined,{anchor:dc.getBoundingClientRect(),focus:'deps'}); }     else if(r) flash(saveInd,'only tasks carry dependencies',2400);     return;   }
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.64.2",+  "version": "0.65.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.64.2",+  "version": "0.65.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+83−39
@@ -1124,7 +1124,6 @@ const MANIFEST = [   // Row tags (0.60.0): the tag box in the item editor and the New panel, its chip x, and the   // batch panel's add and remove. They change the plan; the `row tags` flow proves each.   { sel: '#imTagsIn', label: 'item editor: tags', terminal: true, by: 'row tags' },-  { sel: '#imEffort', label: 'item editor: effort', terminal: true, by: 'effort' },   { sel: '#ncEffort', label: 'new: effort', terminal: true, by: 'effort' },   { sel: '#ncTagsIn', label: 'new: tags', terminal: true, by: 'row tags' },   { sel: '.tb-x', label: 'tag box: remove a chip', terminal: true, by: 'row tags', key: c => c.ds.tb, pin: ['im'] },@@ -1167,22 +1166,12 @@ const MANIFEST = [   { sel: '#tblStatus', label: 'table status filter', terminal: true, by: 'table filters' },   { sel: '#tblSearch', label: 'table search', terminal: true, by: 'table filters' },   { sel: '#toPlanOrder', label: 'table: back to plan order', safe: true },-  // The bar editor (click a bar).-  { sel: '#deStart', label: 'bar editor: start', terminal: true, by: 'editor: bar' },-  { sel: '#deEnd', label: 'bar editor: end', terminal: true, by: 'editor: bar' },-  { sel: '#deNote', label: 'bar editor: note', terminal: true, by: 'editor: bar' },-  { sel: '#deAddDep', label: 'bar editor: add dependency', terminal: true, by: 'editor: bar' },-  { sel: '.dep-rm', label: 'bar editor: remove dependency', terminal: true, by: 'editor: bar' },-  { sel: '#deApply', label: 'bar editor: Apply', terminal: true, by: 'editor: bar' },-  { sel: '#deClose', label: 'bar editor: Cancel', safe: true },-  { sel: '#deDelete', label: 'bar editor: Delete', terminal: true, by: 'editor: bar' },-  // The milestone editor (click a milestone).-  { sel: '#meName', label: 'milestone editor: name', terminal: true, by: 'editor: milestone' },-  { sel: '#meDate', label: 'milestone editor: date', terminal: true, by: 'editor: milestone' },-  { sel: '#meDesc', label: 'milestone editor: description', terminal: true, by: 'editor: milestone' },-  { sel: '#meApply', label: 'milestone editor: Apply', terminal: true, by: 'editor: milestone' },-  { sel: '#meClose', label: 'milestone editor: Cancel', safe: true },-  { sel: '#meDelete', label: 'milestone editor: Delete', terminal: true, by: 'editor: milestone' },+  // 0.65.0: a click on a bar or a milestone opens the item editor; the small bar and+  // milestone panels are gone, and their label and dependencies are fields of the editor.+  { sel: '#imNote', label: 'item editor: label on the bar', terminal: true, by: 'editor: bar' },+  { sel: '#imAddDep', label: 'item editor: waits on (add dependency)', terminal: true, by: 'editor: bar' },+  { sel: '.dep-rm', label: 'item editor: remove a dependency', terminal: true, by: 'editor: bar' },+  { sel: '#imEffort', label: 'item editor: effort', terminal: true, by: 'effort' },   // The full item editor (hover a row label, click its pencil).   { sel: '.row-edit', label: 'row label: Edit all fields (hover only)', safe: true },   { sel: '#imType', label: 'item editor: type', untested: 'switching a task to a milestone in the full editor is not driven' },@@ -1370,7 +1359,7 @@ async function coverageGate(browser, url) {     // A state that renders nothing new would make every check about it vacuous.   }   // The added states must really have opened what they claim to.-  for (const [st, sel] of [['setup: rename', '.sec-nm'], ['setup: aside', '.sec-ld'], ['setup: tag rename', '.tag-nm'], ['setup: tag colour', '.tag-hex'], ['legend: tag filter on', '#tagClear'], ['bar editor', '#deApply'], ['milestone editor', '#meApply'], ['item editor', '#imApply'], ['batch panel', '#bApply'], ['table row hover', '.row-del'], ['chart row hover', '.row-edit'], ['table sorted', '#toPlanOrder']])+  for (const [st, sel] of [['setup: rename', '.sec-nm'], ['setup: aside', '.sec-ld'], ['setup: tag rename', '.tag-nm'], ['setup: tag colour', '.tag-hex'], ['legend: tag filter on', '#tagClear'], ['bar editor', '#imNote'], ['milestone editor', '#imApply'], ['item editor', '#imApply'], ['batch panel', '#bApply'], ['table row hover', '.row-del'], ['chart row hover', '.row-edit'], ['table sorted', '#toPlanOrder']])     check(`state "${st}" opened (its ${sel} rendered)`, seen.has(entryId(MANIFEST.find(d => d.sel === sel))), `controls seen in that state: ${stateControls[st]}`);   await view(page, 'gantt');   check('every rendered control is declared (no orphans)', orphans.length === 0, [...new Set(orphans)].slice(0, 8).join(' | '));@@ -1465,33 +1454,51 @@ async function editorFlows(browser, S) {   const back = async what => { const p = await savedPlan(S.url); check(`${what}: undo puts the plan back`, JSON.stringify(spanOf(p)) === JSON.stringify(spanOf(plan0)), JSON.stringify(spanOf(p))); return p; };    section = 'editor: bar';+  // 0.65.0: a bar click opens THE item editor, beside the bar, with every field of the row.   await clickBar(page, 'finale');-  check('clicking a bar opens its editor', await editorShown(page) && await page.$eval('#deTitle', e => e.textContent.includes('Launch finale')));-  await setVal(page, '#deEnd', '2027-01-20'); await setVal(page, '#deNote', 'late');-  await page.click('#deApply'); await settle(page, 200);+  check('clicking a bar opens the item editor for it', await editorShown(page) && await page.$eval('#imName', e => e.value) === 'Launch finale' && !!(await page.$('#imNote')) && !!(await page.$('#imAddDep')));+  check('there is no other kind of panel for a bar', !(await page.$('#deApply')) && !(await page.$('#deTitle')));+  await setVal(page, '#imEnd', '2027-01-20'); await setVal(page, '#imNote', 'late');+  await page.click('#imApply'); await settle(page, 200);   let p = await savedPlan(S.url); let f = rowOf(p, r => r.id === 'finale');-  check('Apply saves the new end and the note', f.end === '2027-01-20' && f.note === 'late', JSON.stringify({ end: f.end, note: f.note }));+  check('Apply saves the new end and the bar label', f.end === '2027-01-20' && f.note === 'late', JSON.stringify({ end: f.end, note: f.note }));   check('Apply closes the editor', !(await editorShown(page)));   await undo(page); await back('bar editor Apply');   await clickBar(page, 'finale');-  await setVal(page, '#deEnd', '2027-02-20');-  await page.click('#deClose'); await settle(page, 200);+  await setVal(page, '#imEnd', '2027-02-20');+  await page.click('#imCancel'); await settle(page, 200);   p = await savedPlan(S.url);   check('Cancel closes the editor and saves nothing', !(await editorShown(page)) && rowOf(p, r => r.id === 'finale').end === LAST);+  // Dependencies are a draft now: nothing lands until Apply, and Cancel drops them.   await clickBar(page, 'finale');-  await page.select('#deAddDep', 'p0t0'); await settle(page, 200);+  await page.select('#imAddDep', 'p0t0'); await settle(page, 150);+  const rmN = await page.$$eval('#imDepList .dep-rm', e => e.length);+  await page.click('#imDepList .dep-rm[data-dep="gate-a"]'); await settle(page, 150);+  p = await savedPlan(S.url); f = rowOf(p, r => r.id === 'finale');+  check('adding and removing a dependency waits for Apply', JSON.stringify(f.deps) === JSON.stringify(['gate-a']) && rmN === 2, `${rmN} listed; saved ${JSON.stringify(f.deps)}`);+  await page.click('#imApply'); await settle(page, 250);   p = await savedPlan(S.url); f = rowOf(p, r => r.id === 'finale');-  check('picking from "+ add dependency" adds it to the saved plan', (f.deps || []).includes('p0t0'), JSON.stringify(f.deps));-  const rmN = await page.$$eval('#deDepList .dep-rm', e => e.length);-  await page.click('#deDepList .dep-rm[data-dep="gate-a"]'); await settle(page, 200);+  check('Apply lands both: now waits on p0t0 and not gate-a', JSON.stringify(f.deps) === JSON.stringify(['p0t0']), JSON.stringify(f.deps));+  await undo(page); await settle(page, 250);   p = await savedPlan(S.url); f = rowOf(p, r => r.id === 'finale');-  check('the dependency list listed both, and its remove button drops one', rmN === 2 && !(f.deps || []).includes('gate-a') && (f.deps || []).includes('p0t0'), `${rmN} listed, now ${JSON.stringify(f.deps)}`);-  await page.mouse.click(60, 22); await settle(page, 100);-  await undo(page); await undo(page);+  check('one undo restores the dependencies', JSON.stringify(f.deps) === JSON.stringify(['gate-a']), JSON.stringify(f.deps));+  await clickBar(page, 'finale');+  await page.select('#imAddDep', 'p0t1'); await page.click('#imCancel'); await settle(page, 250);   p = await savedPlan(S.url); f = rowOf(p, r => r.id === 'finale');-  check('two undos restore the dependencies', JSON.stringify(f.deps) === JSON.stringify(['gate-a']), JSON.stringify(f.deps));+  check('Cancel drops a drafted dependency', JSON.stringify(f.deps) === JSON.stringify(['gate-a']), JSON.stringify(f.deps));+  // A "blocks" line: gate-a is blocked by nothing, finale waits on gate-a, so gate-a's+  // editor lists "blocks Launch finale", and its x takes gate-a out of finale's list.+  await clickBar(page, 'gate-a');+  check('the editor lists what waits on this task', (await page.$$eval('#imDepList .dep-rm[data-dir="out"]', e => e.map(x => x.dataset.dep))).includes('finale'));+  check('and offers no task that would close a loop (finale waits on gate-a)', !(await page.$$eval('#imAddDep option', o => o.map(x => x.value))).includes('finale'));+  await page.click('#imDepList .dep-rm[data-dir="out"][data-dep="finale"]'); await page.click('#imApply'); await settle(page, 250);+  p = await savedPlan(S.url);+  check('removing a "blocks" line on Apply takes it out of the other task', !(rowOf(p, r => r.id === 'finale').deps || []).includes('gate-a'));+  await undo(page); await settle(page, 250);+  p = await savedPlan(S.url);+  check('and one undo puts it back', (rowOf(p, r => r.id === 'finale').deps || []).includes('gate-a'));   await clickBar(page, 'finale');-  await page.click('#deDelete'); await settle(page, 300);+  await page.click('#imDelete'); await settle(page, 300);   p = await savedPlan(S.url);   check('Delete removes the task', spanOf(p).n === n0 - 1 && !rowOf(p, r => r.id === 'finale'), `${n0} -> ${spanOf(p).n}`);   await undo(page); await back('bar editor Delete');@@ -1499,23 +1506,60 @@ async function editorFlows(browser, S) {   section = 'editor: milestone';   const kick = rowOf(plan0, r => r.name === 'Kickoff');   await clickMilestone(page, kick.uid);-  check('clicking a milestone opens its editor', await editorShown(page) && await page.$eval('#meName', e => e.value) === 'Kickoff');-  await setVal(page, '#meName', 'Kickoff moved'); await setVal(page, '#meDate', '2026-09-22'); await setVal(page, '#meDesc', 'moved a day');-  await page.click('#meApply'); await settle(page, 200);+  check('clicking a milestone opens the item editor for it, with no bar label, effort or dependencies', await editorShown(page) && await page.$eval('#imName', e => e.value) === 'Kickoff'+    && await page.$eval('#imNoteWrap', e => getComputedStyle(e).display) === 'none' && await page.$eval('#imDepsWrap', e => getComputedStyle(e).display) === 'none' && await page.$eval('#imEffWrap', e => getComputedStyle(e).display) === 'none');+  await setVal(page, '#imName', 'Kickoff moved'); await setVal(page, '#imEnd', '2026-09-22'); await setVal(page, '#imDesc', 'moved a day');+  await page.click('#imApply'); await settle(page, 200);   p = await savedPlan(S.url); let k = rowOf(p, r => r.uid === kick.uid);-  check('Apply saves the name, date and description', k.name === 'Kickoff moved' && k.date === '2026-09-22' && k.description === 'moved a day', JSON.stringify(k));+  check('Apply saves the name, date and notes', k.name === 'Kickoff moved' && k.date === '2026-09-22' && k.description === 'moved a day', JSON.stringify(k));   await undo(page); p = await back('milestone Apply');   check('and undo restores the name', rowOf(p, r => r.uid === kick.uid).name === 'Kickoff');   await clickMilestone(page, kick.uid);-  await setVal(page, '#meDate', '2026-12-01'); await page.click('#meClose'); await settle(page, 200);+  await setVal(page, '#imEnd', '2026-12-01'); await page.click('#imCancel'); await settle(page, 200);   p = await savedPlan(S.url);   check('Cancel saves nothing', !(await editorShown(page)) && rowOf(p, r => r.uid === kick.uid).date === FIRST);   await clickMilestone(page, kick.uid);-  await page.click('#meDelete'); await settle(page, 300);+  await page.click('#imDelete'); await settle(page, 300);   p = await savedPlan(S.url);   check('Delete removes the milestone', spanOf(p).n === n0 - 1 && !rowOf(p, r => r.uid === kick.uid));   await undo(page); await back('milestone Delete'); +  // The name in the label column opens the same editor (0.65.0), and so does the Deps cell.+  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));+  await page.keyboard.press('Escape'); await settle(page, 150);+  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'));+  await page.keyboard.press('Escape'); await settle(page, 150);+  await view(page, 'gantt');++  // One picker per kind of field: the app's list for a select, one owner picker.+  await openItemEditor(page, 'Lane 1 task 3');+  const ph = await page.$eval('#imPhase', e => { const r = e.getBoundingClientRect(); return { x: r.left + 20, y: r.top + r.height / 2 }; });+  await page.mouse.click(ph.x, ph.y); await settle(page, 150);+  check('the section select opens the app\'s own list, above the panel', await page.evaluate(() => { const l = document.querySelector('.cell-list:not(.owner-list)'); return !!l && +getComputedStyle(l).zIndex > +getComputedStyle(document.getElementById('editor')).zIndex; }));+  await page.mouse.click(ph.x, ph.y); await settle(page, 100);+  await page.click('#imOwner'); await settle(page, 150);+  const ol = await page.$$eval('.owner-combo .cl-opt', e => e.map(x => x.textContent));+  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')));+  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);+  await view(page, 'gantt');+   section = 'editor: item';   const t1 = rowOf(plan0, r => r.name === 'Lane 1 task 1');   check('hovering a row label reveals its pencil', await openItemEditor(page, 'Lane 1 task 1'));