← Commit history

0.36.0: square task editor that remembers its position, editable owner pill on the chart, Enter finishes a rename

Drew Owens ·67fa88cc67 · ·parent c584f31
A. The task editor panel is square (344px a side) and draggable by the strip at its top. The moment it is dragged, that position is remembered for the session and every subsequent panel opens there, in viewport coordinates so it is independent of chart scroll. Placement is now one chokepoint (placeEditor behind showEditor) instead of five hand-rolled sites, the drag and the placement share one clamp margin, and a resize re-clamps rather than forgets. In memory only: a reload restores auto-placement. B. Every task row carries an owner pill immediately right of the status pill, showing the owner or a quiet unassigned. Clicking it opens the people already on the plan plus a field for a name that is not on the list yet. It commits through commitCell, the same path the table's Owner cell uses, so it is one undo, one changelog event, and both lenses agree. .row-name became a flex row so the chips can never be the part that gets clipped on a long name. C. Enter on an inline rename now commits, CLOSES the field and leaves the row selected. The field used to survive Enter, Escape and blur alike as a live overlay, which meant a row could be renamed only once per load and left labelEditing stranded, taking the table's arrows, Delete and Tab out of service. Both are cleared in one place now. Verified headless on synthetic fixtures, both lenses, zero JS errors: 75 assertions in a new abc suite plus the 26-assertion standing battery.
5 files changed +412−31
README.md+6
@@ -32,6 +32,12 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau  **Status is editable from the chart.** Click a task's status pill in the label column to change it from a small on-brand dropdown (not started / in progress / blocked / complete). It is the same edit as the table's status cell: one undo, logged to the changelog, reflected in both lenses. +**Owner is editable from the chart too.** Right of the status pill, every task row carries an owner pill, showing who has it or a quiet "unassigned" when nobody does. Click it for a list of everyone already on the plan, plus a field for a name that is not on it yet; a new name is offered to every other row the moment it commits. Same path as the table's Owner cell, so it is one undo, one changelog event, and both lenses agree.++**Rename a row in place.** Click the name text in the label column to edit it. Enter commits the new name, closes the field and leaves the row selected, so the rename hands straight over to the keyboard. Escape cancels.++**The task editor remembers where you put it.** The panel that opens on a bar is square and draggable by the strip at its top. Drag it once and every panel after it opens in that same spot on screen, whatever row it belongs to and however far the chart has been scrolled, until the page is reloaded. Nothing is saved to the plan; it is a property of the sitting, and a refresh restores the automatic placement beside the bar.+ **Multi-select.** Ctrl+click to toggle individual rows, Shift+click for range select (Windows Explorer style), in either lens. Tasks and milestones both, since both have a permanent id to be named by; lane headers do not join. The batch editor applies relative date shifts across the selected tasks without flattening dates, and says how many milestones it is leaving alone: a milestone has one date and no duration, so shifting its end has no meaning.  **Vertical reorder.** Drag bars or labels up/down to reorder rows. Drop indicator shows insertion point. Multi-select reorder moves all selected items together.
docs/BUILD-NOTES.md+124
@@ -3,6 +3,130 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-18 (0.36.0, the task panel is square and stays where you put it, the owner is a pill on the chart, and Enter finishes a rename)++Three changes, all in the client. `server.js` is untouched, so the running 0.35.0+server was never restarted.++**A. The task editor is square, and the position the user chooses outlives the panel.**+The panel that opens on a bar now carries a `.square` class and a `--ed-sq` side (344px)+used for both the width and the height in CSS and for the placement maths in JS, so the+two cannot disagree about how big the box is when it clamps. It is a flex column: the+dependency list is the only part that can grow, so it is the only part that scrolls, and+the actions are pinned to the bottom edge, which puts Apply in the same place on every+task. Only this panel is square. The milestone editor, the batch panel, the full item+modal and the new-row card are deliberately left alone: they have different content, some+of them are taller than they are wide, and forcing the aspect on them would buy a+scrollbar and nothing else.++Placement went through five hand-rolled sites, each with its own half-clamp. They are now+one chokepoint, `placeEditor(x,y)` behind `showEditor(x,y,square)`, and every opener goes+through it. Callers still say where they would LIKE the panel; if the user has dragged one+this session, `edManualPos` overrides the wish and every subsequent panel opens in that+same spot, whichever row it belongs to and however far the chart has been scrolled. The+position is held in VIEWPORT coordinates, which is the whole reason it is independent of+scroll: it records where on the screen the user put the thing, not where in the plan. It+is in memory only and deliberately so, so it goes nowhere near the sidecar or the+changelog and a reload restores auto-placement.++`showEditor` adds `.show` BEFORE positioning, on purpose: a `display:none` box measures+zero and the clamp is only honest against the real one. Both happen in one task, so+nothing paints in between and there is no flash at the old position.++Two things the drag needed. The drag and the placement used DIFFERENT minimums (0 and 8),+so a panel dragged flush to an edge reopened eight pixels from where it was left, which is+exactly the kind of small lie this feature cannot afford; both now share `ED_MARGIN` via+`edClampX`/`edClampY`, and the drag clamps against all four edges while it moves rather+than only the top and left. And a real move is distinguished from a bare click on the grab+strip (`edDrag.moved`), so only a genuine drag pins anything. Text selection was already+safe (preventDefault fires only for a `.drag-bar` target, never for the fields), and a+`body.ed-dragging` class now suppresses selection across the page for the duration of the+drag only. A window resize re-clamps a remembered position rather than forgetting it: the+choice survives, it just moves back into view.++**B. The owner is a pill on the chart, right of the status pill, and it is editable.**+`nameInner` now emits an owner chip after the status chip on every task row, showing the+owner or a quieter italic "unassigned" when there is none, because a row with nobody on it+is the state most worth seeing and it has to be clickable to be fixable. Milestones keep+their bare name: they carry no status pill, so "right of status" has nothing to sit next+to there.++The click branch is a copy of the status pill's, placed immediately after it, so the owner+pill is simply one more excluded region: a plain click on it edits the owner and goes no+further, a modified click still falls through to selection, and the 0.34.0 name-text and+gap-click behaviours are untouched by it. The drag-arm exclusion was widened the same way,+so a press on either pill is an edit and never the start of a reorder.++`openOwnerPill` commits through `commitCell(r,'owner',v)`, which is the SAME call the+table's Owner cell makes, so a name set on the chart and a name typed in the table are one+kind of edit with one undo step, one sidecar override and one `owner` event in the+changelog. Undo needed no new code: `applySnapshot` already restored `owner` and already+called `refreshLabelRow`, so the chip reverts with everything else. The list carries the+one thing the status list does not, a type-in row, because status is a closed set of four+and the people on a project are an open set; a dropdown alone would be a dead end the+first time someone joins. The commit re-renders the table, which refreshes the owner filter+and the datalist too, so a brand new name is offered everywhere an owner can be chosen the+moment it lands.++**The layout consequence, which had to be fixed with it.** `.row-name` was a block of+inline spans with `overflow:hidden`. A long name plus two chips simply overflowed, and the+clipping took a bite out of whichever came last, which from this build is the owner pill: a+CONTROL, clipped to the point of being unclickable. It is now a flex row with the chips at+`flex:none` and the name as the flexible part, so a long name ellipsises and both chips+stay whole and hittable at any label width. Measured on the fixture, a row that previously+pushed its owner pill 17px past the column edge now leaves a 34px gap with the pill intact.++**C. Enter finishes a rename, and hands the row to the keyboard.** Enter already committed+the name. It did not close anything: `restore()` only rewrote the name SPAN, and the input+is a SIBLING of that span, so the field survived Enter, Escape and blur alike as a live+overlay sitting on the row. The next click on that row then hit the+already-has-an-editor guard at the top of `editLabelInline` and did nothing at all, so a+row could be renamed exactly once per load. `labelEditing` was stranded the same way, and+`appIsIdle()` reads it, so one inline edit also quietly took the table's arrows, Delete and+Tab out of service for the rest of the session. Both are cleared in a new `dismiss()` that+every exit passes through. Measured against a pristine 0.35.0 served alongside: one live+editor left behind there, zero here.++Enter then selects the row, through a new `selectOnly(r)` rather than `doSelect`. `doSelect`+is the CLICK path and a click TOGGLES, which is right for a pointer and wrong here: the+promise is "this row is selected", not "this row's selection was flipped", and a row that+happened to be the sole selection already would have been dropped. `selectOnly` also sets+the table's focused row to the same row, so the two lenses agree about what is current and+the arrows pick up from the row just renamed. Escape is unchanged and still cancels without+committing. Groups have no uid to select, so this is inert on a lane header.++**Verified headless on synthetic fixtures, both lenses, zero JS errors.** A new suite,+`abc`, covers all three in 75 assertions: the panel is 344x344 with Apply inside and no+panel scroll; a drag to the top-left pins it and the next two panels open at exactly that+point, after Cancel and after Apply alike; the chart is scrolled 260px and the fourth panel+still opens at the same viewport point; the clamp holds at both corners (1248,648 against a+1600x1000 viewport, and 8,8); a drag started inside the note field moves nothing and the+field still takes text and a selection; a refresh restores anchoring beside the bar. For B:+22 pills, each after its status pill in the DOM and to its right on screen, the list is+"unassigned" plus everyone on the plan, picking updates the pill and the table and the+sidecar, undo reverts it, a typed name commits and is then in the list for other rows, and+clicking the pill starts no name edit, selects no row and reorders nothing. For C: Enter+commits, leaves zero live editors, leaves the row selected with its bar outlined, the row+is renameable again, the table focus lands on it and ArrowDown/ArrowUp walk from it, and+Escape cancels. The chips are also asserted to share the status pill's mono face, size,+padding and corner, on the same baseline with the 6px tag gap, in both themes.++The standing battery (`regress`, 26 assertions covering the status pill and its undo, the+0.34.0 name-text and gap-click hit targets, ctrl-click multi-select, the dependency editor,+bar drag with its sidecar write and undo, both lenses, table arrow nav and the table's owner+cell, lane headers and milestone caps) is green. 101 assertions in total, zero JS errors.++**One pre-existing quirk found and deliberately not fixed.** Close a panel, scroll the+chart, then click a bar: that first click is swallowed and the second one works. The same+script reproduces it identically against a pristine 0.35.0 served on another port, so it is+not this build's doing; it lives in the `gesture`/`didDrag` state machine, which is the+drag subsystem this round had no mandate to touch. The suites retry once and say why.++**Fixture note, still true.** Fixtures mirror the real steady state: zero-padded string+uids and `project.nextUid`, so the server mints nothing at startup. And a suite must reset+the sidecar and the changelog between runs, or a run that invents an owner leaves the name+behind for the next run's dropdown assertion to trip over.+ ## Build notes, 2026-08-17 (0.35.0, the changelog is now tamper-evident — a hash chain catches out-of-band edits)  The HTTP door stops a stranger from POSTing to `/log`, but it cannot stop a trusted
gantt.html+280−29
@@ -50,6 +50,10 @@      Used when a phase declares no explicit colour. */   --ph1: #00b8b1; --ph2: #8c6bf7; --ph3: #64abff;   --bar-opacity: 0.82;+  /* The side of the square task editor. One number, used for both the width and the+     height in CSS and for the placement maths in JS, so the two can never disagree+     about how big the panel is when it clamps itself to the viewport. */+  --ed-sq: 344px;   color-scheme: dark; } :root[data-theme="light"] {@@ -233,6 +237,14 @@ body.col-resizing { cursor: col-resize; user-select: none; } .tag { display: inline-block; font-family: 'JetBrains Mono', monospace; font-size: 9px; padding: 1px 5px; border-radius: 4px; margin-left: 6px; vertical-align: middle; } .label-row .status-pill { cursor: pointer; } .label-row .status-pill:hover { box-shadow: inset 0 0 0 1px currentColor; filter: brightness(1.12); }+/* The owner pill sits immediately right of the status pill and is the same kind of+   object: a mono chip that is also the control that edits the thing it shows. It+   borrows the .tag box so the two chips line up exactly rather than agreeing by+   coincidence. An unowned row still gets a chip, quieter and italic, because a row+   with nobody on it is a thing worth seeing and worth being able to click. */+.label-row .owner-pill { cursor: pointer; background: rgba(139,148,158,0.10); color: var(--text2); max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.label-row .owner-pill:hover { box-shadow: inset 0 0 0 1px currentColor; filter: brightness(1.12); }+.label-row .owner-pill.none { font-style: italic; opacity: 0.62; } /* The name text is its own hit target now: only the glyphs open the editor, and the    empty space after them selects the row. The margin cancels the padding so the box    hugs the text without nudging the row sideways; the hover outline is the affordance@@ -316,10 +328,28 @@ body.col-resizing { cursor: col-resize; user-select: none; }  /* ── Editor panel (single + batch) ── */ .editor { position: fixed; z-index: 99998; background: var(--elevated); border: 1px solid var(--border); border-radius: 8px; padding: 0 14px 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); font-family: 'Satoshi', sans-serif; display: none; min-width: 280px; }-.editor .drag-bar { height: 28px; cursor: grab; display: flex; align-items: center; justify-content: center; margin: 0 -14px 8px; padding: 0 14px; border-radius: 8px 8px 0 0; }+.editor .drag-bar { height: 28px; cursor: grab; display: flex; align-items: center; justify-content: center; margin: 0 -14px 8px; padding: 0 14px; border-radius: 8px 8px 0 0; flex: none; user-select: none; -webkit-user-select: none; } .editor .drag-bar:active { cursor: grabbing; } .editor .drag-bar::after { content: ''; width: 32px; height: 3px; border-radius: 2px; background: var(--border); } .editor.show { display: block; }+/* The task editor is SQUARE: one side, the same both ways, so the panel reads as one+   deliberate object wherever it has been dragged to instead of a box that changes+   shape with the length of its dependency list. Only that list can grow, so it is the+   only part that scrolls, and the actions are pinned to the bottom edge so Apply is+   in the same place on every task. The other panels on this element (the milestone+   editor, the batch panel, the full item modal, the new-row card) are deliberately+   NOT square: they have different content and some of them are taller than they are+   wide, so forcing the aspect on them would only add a scrollbar. */+.editor.square.show { display: flex; }+.editor.square { width: var(--ed-sq); height: var(--ed-sq); box-sizing: border-box; min-width: 0; flex-direction: column; }+.editor.square .de-dep-section { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }+.editor.square .de-dep-section .dep-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }+.editor.square .de-dep-section > select { flex: none; }+.editor.square .de-actions { flex: none; margin-top: 10px; }+/* While the panel is being dragged nothing on the page should be selecting text: the+   pointer is moving a window, not sweeping a paragraph. Scoped to the drag so the+   inputs inside the panel keep normal selection the rest of the time. */+body.ed-dragging { user-select: none; -webkit-user-select: none; } .editor label { display: block; font-size: 11px; color: var(--text2); margin-bottom: 4px; font-family: 'JetBrains Mono', monospace; } .editor input, .editor textarea, .editor select { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 12px; padding: 5px 8px; outline: none; } .editor input[type="date"], .editor input[type="number"] { width: 130px; }@@ -576,7 +606,15 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .label-row:hover .row-edit { opacity: 1; pointer-events: auto; } .row-edit:hover { color: var(--accent); border-color: var(--accent); } .row-edit:focus-visible { opacity: 1; pointer-events: auto; outline: 1px solid var(--accent); }-.label-row .row-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-right: 22px; }+/* A flex row rather than a block of inline spans, so the two pills are never the part+   that gets cut off. As a block, a long name plus a status pill plus an owner pill+   simply overflowed and `overflow:hidden` took a bite out of whichever came last,+   which from 0.36.0 is the owner pill: a CONTROL, clipped to the point of being+   unclickable. Flexed, the pills are flex:none and the name is the flexible part, so a+   long name ellipsises and both chips stay whole and hittable at any label width. */+.label-row .row-name { display: flex; align-items: center; min-width: 0; overflow: hidden; white-space: nowrap; padding-right: 22px; }+.label-row .row-name .name-text { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }+.label-row .row-name .tag { flex: none; } /* ---- resizable table columns ---- */ .data-table th { position: relative; } .col-grip { position: absolute; top: 0; right: 0; width: 8px; height: 100%; cursor: col-resize; z-index: 3; }@@ -595,6 +633,14 @@ body.col-sizing { cursor: col-resize; user-select: none; } .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.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); }  /* 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@@ -2518,7 +2564,13 @@ function nameInner(r){   if(r.type==='milestone')return nt;   const sk=statusOf(r);   const tg=sk?`<span class="tag status-pill ${statusTagFor(sk)}" title="Click to change status">${esc(statusTextFor(sk))}</span>`:'';-  return nt+tg;+  // The owner rides immediately right of the status, the same kind of chip answering+  // the other question a row raises: not just how it is going, but whose it is. A row+  // with nobody on it still gets a chip, quieter, because "unassigned" is the state+  // most worth seeing at a glance and it has to be clickable to be fixable.+  const ow=(r.owner||'').trim();+  const op=`<span class="tag owner-pill${ow?'':' none'}" title="Click to change owner">${esc(ow||'unassigned')}</span>`;+  return nt+tg+op; }  // Extracted from the initial build loop so a row created at runtime is built by@@ -2937,6 +2989,24 @@ function doSelect(uid, e){   }   refreshSelVisuals();updateSelCount(); }+// Make this row the selection, full stop. doSelect is the CLICK path and a click+// toggles, which is right for a pointer and wrong here: after committing a rename the+// promise is "this row is selected", not "this row's selection was flipped", and a row+// that happened to be the sole selection already would have been dropped. Groups have+// no uid to select, so this is inert on a lane header.+//+// The table's focused row is set to the same row on the way through, so the two lenses+// agree about what is current and the arrow keys pick up from the row you just renamed+// rather than from wherever the focus happened to be left.+function selectOnly(r){+  const u=selUid(r); if(!u)return;+  selected.clear(); selected.add(u); selAnchor=u;+  refreshSelVisuals(); updateSelCount();+  if(typeof setFocusedRow==='function'){+    const k=rowKey(r);+    if(k)setFocusedRow(k);+  }+} // The rows the selection names, in plan order, which is the order every operation // on a set walks them in. function selectedRows(){return items.filter(r=>r.uid&&selected.has(r.uid));}@@ -3032,6 +3102,19 @@ labelsEl.addEventListener('click',e=>{     if(pr&&pr.id)openStatusPill(pr,pill);     return;   }+  // The owner pill is the same kind of control as the status pill and gets the same+  // deal: a plain click on it edits the owner and goes no further, so it never also+  // opens the name editor or selects the row, and a modified click falls through to+  // selection. One more excluded region, exactly like the status pill, which is why+  // the gap-click and name-text behaviours below are untouched by it.+  const opill=e.target.closest('.owner-pill');+  if(opill&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey){+    e.stopPropagation();+    const prow=e.target.closest('.label-row[data-id]');+    const pr=prow&&items.find(x=>x._lDiv===prow);+    if(pr&&pr.id)openOwnerPill(pr,opill);+    return;+  }   const row=e.target.closest('.label-row[data-id], .label-row.ms, .label-row.group');   // Empty space in the label column is a click on nothing, which is how every list   // in the world drops a selection.@@ -3114,7 +3197,21 @@ function editLabelInline(r){   inp.type='text';inp.className='label-edit';   inp.value=isHdr?((phases[r.phase]&&phases[r.phase].name)||''):(r.name||'');   let done=false;+  // Take the field away and give the app back its idle state. Neither used to happen+  // on the Enter, Escape or blur paths: restore() only rewrote the name SPAN, and the+  // input is a SIBLING of that span, so it survived every one of them as a live+  // overlay sitting on the row. The next click on that row then hit the+  // already-has-an-editor guard at the top of this function and did nothing at all, so+  // a row could be renamed exactly once. `labelEditing` was stranded the same way, and+  // appIsIdle() reads it, so one inline edit also quietly took the table's arrows,+  // Delete and Tab out of service for the rest of the session. Both are cleared here,+  // in the one place every exit passes through.+  const dismiss=()=>{+    if(inp.parentNode)inp.remove();+    if(labelEditing===inp)labelEditing=null;+  };   const restore=()=>{+    dismiss();     if(isDerivedGroup(r))refreshHeaderRow(r);     else if(r.type==='milestone')refreshMsChrome(r); else refreshLabelRow(r);     applyCollapse();@@ -3123,6 +3220,7 @@ function editLabelInline(r){     if(done)return;done=true;     const v=inp.value.trim();     try{inp.blur();}catch(e){}+    dismiss();     if(isHdr){       const p=phases[r.phase];       if(save&&v&&p&&v!==p.name){ renamePhase(r.phase,v); } else restore();@@ -3139,8 +3237,12 @@ function editLabelInline(r){   };   inp.__cancel=()=>{done=true;restore();};   inp.__finish=finish;+  // Enter is the whole gesture, not half of it: it commits the name, closes the field+  // and hands the row over to the keyboard still SELECTED, so a rename runs straight+  // into whatever comes next instead of ending on a row that is no longer anything.+  // Escape is unchanged and still cancels without committing.   inp.addEventListener('keydown',e=>{e.stopPropagation();-    if(e.key==='Enter'){e.preventDefault();finish(true);}+    if(e.key==='Enter'){e.preventDefault();finish(true);selectOnly(r);}     else if(e.key==='Escape'){e.preventDefault();finish(false);}});   inp.addEventListener('blur',()=>finish(true));   inp.addEventListener('mousedown',e=>e.stopPropagation());@@ -3173,7 +3275,7 @@ function hideDropIndicators(){  function wireLabelDragArm(){ labelsEl.addEventListener('mousedown',e=>{-  if(e.target.closest('.status-pill'))return;   // editing status, not starting a drag+  if(e.target.closest('.status-pill, .owner-pill'))return;   // editing a pill, not starting a drag   const row=e.target.closest('.label-row[data-id]');   if(!row)return;   gesture={id:row.dataset.id,startX:e.clientX,startY:e.clientY,src:'label'};@@ -3573,9 +3675,7 @@ function openMilestoneEditor(uid){     <div class="de-actions"><button class="de-btn primary" id="meApply">Apply</button>       <button class="de-btn secondary" id="meClose">Cancel</button></div>`;   const x=r._msHit?r._msHit.getBoundingClientRect():{left:window.innerWidth/2,bottom:200};-  editorEl.style.left=Math.min(Math.max(8,x.left-150),window.innerWidth-320)+'px';-  editorEl.style.top=Math.min(x.bottom+8,window.innerHeight-300)+'px';-  editorEl.classList.add('show');+  showEditor(x.left-150,x.bottom+8,false);   $('meApply').addEventListener('click',()=>{     snapshotMs(r);     const nd=$('meDate').value;@@ -3599,13 +3699,13 @@ function openSingleEditor(id,anchor){   // 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();-  const edW=300, edH=300;-  let edX=rect.right+8, edY=rect.top;-  if(edX+edW>window.innerWidth) edX=rect.left-edW-8;-  if(edX<0) edX=8;-  if(edY+edH>window.innerHeight) edY=window.innerHeight-edH-8;-  if(edY<0) edY=8;-  editorEl.style.left=edX+'px';editorEl.style.top=edY+'px';editorEl.classList.add('show');+  // 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,true);   $('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);@@ -3658,7 +3758,7 @@ function openBatchEditor(){       <div class="batch-row"><label>shift end</label><input type="number" id="bShiftE" value="0" step="1"> <span>days</span></div>     </div>     <div class="de-actions"><button class="de-btn primary" id="bApply">Apply</button><button class="de-btn secondary" id="bClose">Cancel</button></div>`;-  editorEl.style.left=(window.innerWidth/2-155)+'px';editorEl.style.top=(window.innerHeight/2-130)+'px';editorEl.classList.add('show');+  showEditor(window.innerWidth/2-155,window.innerHeight/2-130,false);    $('bApply').addEventListener('click',()=>{     const newStart=$('bStart').value, newEnd=$('bEnd').value;@@ -3709,7 +3809,10 @@ function refreshMsChrome(r){ // description restored from the sidecar (or edited in the table) left the bar // hovering the version that was in the data file. function updateBar(id){const r=items.find(d=>d.id===id),be=barEls[id];if(!r||!be)return;if(r._noteEl)r._noteEl.textContent=r.note||'';const x1=toX(r.start),x2=toX(r.end);be.bar.style.left=x1+'px';be.bar.style.width=Math.max(x2-x1,6)+'px';be.bar.style.background=phaseColor(r.phase);be.bar.dataset.dates=fmt(r.start)+' → '+fmt(r.end);be.bar.dataset.title=r.name;be.bar.dataset.body=r.description||'';be.bar.dataset.prov=provLabel(r);drawDeps();}-function closeEditor(){editorEl.classList.remove('show');editorEl.innerHTML='';}+// The shape goes with the content: every opener declares whether it wants the square,+// so closing has to leave the element without one rather than let the last panel's+// aspect ride along into the next one.+function closeEditor(){editorEl.classList.remove('show','square');editorEl.innerHTML='';} function closeMenus(){exportMenu.classList.remove('show');importMenu.classList.remove('show');}  // ── Drag (edge-resize, body-move horizontal, reorder vertical) ──@@ -4369,9 +4472,7 @@ function openItemModal(r,carriedName){   $('imDesc').value=r.description||'';   $('imProv').value=p.label||'';   const rect=(r._lDiv||document.body).getBoundingClientRect();-  editorEl.style.left=Math.max(8,Math.min(rect.right+12,window.innerWidth-340))+'px';-  editorEl.style.top=Math.max(8,Math.min(rect.top,window.innerHeight-430))+'px';-  editorEl.classList.add('show');+  showEditor(rect.right+12,rect.top,false);   $('imName').focus();$('imName').select();   editorEl.addEventListener('keydown',e=>{e.stopPropagation();     if(e.key==='Enter'&&e.target.tagName!=='BUTTON'){e.preventDefault();$('imApply').click();}@@ -4460,9 +4561,7 @@ function openCreateEditor(){       <button class="de-btn secondary" id="ncCancel">Cancel</button></div>     <div id="ncErr" style="color:#f85149;font-size:11px;margin-top:6px;display:none"></div>`;   const r=$('btnNew').getBoundingClientRect();-  editorEl.style.left=Math.max(8,Math.min(r.left-220,window.innerWidth-340))+'px';-  editorEl.style.top=(r.bottom+8)+'px';-  editorEl.classList.add('show');+  showEditor(r.left-220,r.bottom+8,false);   // The three selects carry too, and they are set BEFORE syncType runs: a carried   // milestone has to open with its start field already hidden and its second date   // already labelled "date", not flip a frame later. A carried phase that no longer@@ -4782,21 +4881,101 @@ if(prefs.labelW) applyLabelWidth(prefs.labelW); document.addEventListener('click',e=>{if(!e.target.closest('.dropdown')&&!e.target.closest('.act-btn'))closeMenus();}); } -// ── Draggable editor ──-let edDrag=null;+// ── Draggable editor, and the position the user chose ──+//+// Auto-placement anchors a panel next to the thing it edits, which is right the first+// time and wrong the moment the user has an opinion. So the moment they DRAG the+// panel, that is the answer: the position is remembered and every panel opened after+// it lands in the same spot, whichever row it belongs to, however far the chart has+// been scrolled. The position is held in VIEWPORT coordinates, which is what makes it+// independent of scroll: it is where on the screen the user put the thing, not where+// in the plan.+//+// In memory only, and deliberately. It is a property of this sitting at this window+// size, not of the plan, so it goes nowhere near the sidecar or the changelog and a+// reload puts auto-placement back. Nothing about it is a preference to save.+let edDrag=null, edManualPos=null;+// The panel's own size, for clamping. Read from the live box when it has one; the+// square side is the fallback for a panel that has not been laid out yet.+function edSize(){+  const r=editorEl.getBoundingClientRect();+  const sq=parseInt(getComputedStyle(document.documentElement).getPropertyValue('--ed-sq'),10)||344;+  return {w:r.width||sq, h:r.height||sq};+}+// The one place a panel is positioned. Callers pass where they WOULD like it; if the+// user has dragged a panel this session that wish is overridden, and either way the+// result is clamped so no panel can open half off the screen. Every open path on this+// element goes through here, so "it opens where I put it" is true of all of them and+// cannot drift as panels are added.+// One margin for both the drag and the placement. They used to disagree (the drag+// stopped at 0, placement at 8), so a panel dragged flush to an edge reopened eight+// pixels away from where it was left, which is exactly the kind of small lie this+// feature cannot afford: the promise is that it opens where you put it.+const ED_MARGIN=8;+function edClampX(x,w){ return Math.round(Math.max(ED_MARGIN,Math.min(x,window.innerWidth-w-ED_MARGIN))); }+function edClampY(y,h){ return Math.round(Math.max(ED_MARGIN,Math.min(y,window.innerHeight-h-ED_MARGIN))); }+function placeEditor(x,y){+  if(edManualPos){x=edManualPos.left;y=edManualPos.top;}+  const {w,h}=edSize();+  editorEl.style.left=edClampX(x,w)+'px';+  editorEl.style.top =edClampY(y,h)+'px';+}+// The square side, as a number, for the auto-placement maths. Read from the same+// custom property the stylesheet sizes the panel with, so there is one source of it.+function edSquareSide(){+  return parseInt(getComputedStyle(document.documentElement).getPropertyValue('--ed-sq'),10)||344;+}+// Show the panel in the shape this caller needs, then position it. Shown BEFORE+// placing on purpose: a display:none box measures zero, and the clamp is only honest+// if it measures the real one. Both happen inside one task, so nothing paints between+// them and there is no flash at the old position.+function showEditor(x,y,square){+  editorEl.classList.toggle('square',!!square);+  editorEl.classList.add('show');+  placeEditor(x,y);+} function wireEditorDrag(){ editorEl.addEventListener('mousedown',e=>{+  // Only the grab strip starts a drag, so preventDefault (which is what stops the+  // pointer sweeping a text selection across the panel) never touches the inputs:+  // click, drag-select and caret placement inside a field are all untouched.   if(!e.target.closest('.drag-bar'))return;   e.preventDefault();   const rect=editorEl.getBoundingClientRect();-  edDrag={sx:e.clientX-rect.left,sy:e.clientY-rect.top};+  edDrag={sx:e.clientX-rect.left,sy:e.clientY-rect.top,moved:false}; }); document.addEventListener('mousemove',e=>{   if(!edDrag)return;-  editorEl.style.left=Math.max(0,e.clientX-edDrag.sx)+'px';-  editorEl.style.top=Math.max(0,e.clientY-edDrag.sy)+'px';+  if(!edDrag.moved){+    edDrag.moved=true;+    document.body.classList.add('ed-dragging');+  }+  const {w,h}=edSize();+  // Clamped while it moves, not just where it lands, so the panel cannot be shoved+  // past an edge and left with its Apply button off the screen. Same clamp the+  // placement uses, so where it stops is exactly where it will reopen.+  editorEl.style.left=edClampX(e.clientX-edDrag.sx,w)+'px';+  editorEl.style.top =edClampY(e.clientY-edDrag.sy,h)+'px';+});+document.addEventListener('mouseup',()=>{+  if(edDrag&&edDrag.moved){+    // A real move, so the user has chosen a place. A bare click on the grab strip is+    // not a choice and deliberately does not pin anything.+    const r=editorEl.getBoundingClientRect();+    edManualPos={left:r.left,top:r.top};+  }+  document.body.classList.remove('ed-dragging');+  edDrag=null;+});+// A window resize can leave a remembered position off the edge of the smaller+// viewport, so it is re-clamped rather than forgotten: the user's choice survives,+// it just moves back into view.+window.addEventListener('resize',()=>{+  if(!edManualPos)return;+  const {w,h}=edSize();+  edManualPos={left:edClampX(edManualPos.left,w), top:edClampY(edManualPos.top,h)};+  if(editorEl.classList.contains('show'))placeEditor(edManualPos.left,edManualPos.top); });-document.addEventListener('mouseup',()=>{edDrag=null;}); }  // ── Table view ──@@ -5540,6 +5719,78 @@ function openStatusPill(r,pillEl){   // Deferred, so the click that opened the list does not immediately close it.   setTimeout(()=>{ document.addEventListener('mousedown',outside,true); document.addEventListener('keydown',esc,true); },0); }++// 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+// 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 four; 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.+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="">unassigned</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.+  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';++  let done=false;+  const cleanup=()=>{+    if(done)return; done=true;+    el.remove();+    document.removeEventListener('mousedown',outside,true);+    window.removeEventListener('scroll',cleanup,true);+    window.removeEventListener('resize',cleanup);+  };+  // 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(); };+  el.addEventListener('mousedown',ev=>{ if(!ev.target.closest('.cl-new-inp'))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(); }+  });+  // 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);+    window.addEventListener('scroll',cleanup,true);+    window.addEventListener('resize',cleanup);+  },0);+} function openCellList(sel,td){   closeCellList();   const el=document.createElement('div');
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.35.0",+  "version": "0.36.0",   "description": "Interactive Gantt chart viewer for project timelines, with a table view 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. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. 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.35.0",+  "version": "0.36.0",   "description": "Interactive Gantt chart viewer for project timelines, with a table view 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. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. 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": {