← Commit history

0.40.0: the compact task panel names the row it is editing, and an armed dependency source uses the normal selection outline

Drew Owens ·5755537b96 · ·parent 18a3774
4 files changed +211−41
docs/BUILD-NOTES.md+120
@@ -3,6 +3,126 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-18 (0.40.0, the compact panel says which task it is on, and an armed dependency source is just a selected bar)++Two changes, both from Drew, both in the client. `server.js` is untouched, so the running+server was never restarted.++**A. The compact task panel carries the name of the row it is editing.**++The square panel (start, end, the note shown on the bar, the dependency list, Apply/Cancel/+Delete) is the one panel in this app with no name field, on purpose: the full Edit item modal+owns the name and one editable name per row is enough. The cost of that was that it never said+WHICH task it was on. Drew's way of finding out was to close it and hover the bar.++So the name goes at the top, under the grab strip, as read-only text. Not an input: this is+identification, not a second place to rename, and duplicating the modal's field here would+give the app two answers to "where do I rename a row". It is styled as `.batch-title` already+is (Familjen Grotesk 13/600 in the accent), which is what this app has used to name a thing+since the tooltip was written, and the suite asserts the match by computing both rather than+by trusting that the numbers were copied correctly.++One line, ellipsised, never wrapped, with the full name on the `title` attribute. That is the+square talking: a name that wrapped to two lines would take a row off the dependency list+under it, and the panel would change shape with the length of a name, which is the exact thing+0.36.0 made it square to stop. Measured on a 703px name in a 340px box, it clips at one line+and ends in an ellipsis in both themes.++The header costs the panel 26px of height (an 18px line, less its 2px lift, plus its 10px+gap). The dependency list is the only part of the panel that flexes, so at the old side that+26px came straight out of the list: measured, 60px to 34px, half a row shorter than 0.36.0+sized it. So `--ed-sq` goes 344 to 370, the same 26. One token is both the width and the+height, in CSS and in the JS placement maths, so paying the header in the token keeps the+panel square by construction rather than by a second number someone has to remember to change.+The two `||344` fallbacks in `edSize` and `edSquareSide` moved with it.++The header live-updates through `syncEditorDates`, the 0.37.0 chokepoint, rather than through+a new path. Every route that changes a row already arrives there: `updateBar` calls it for+tasks and `updateMilestone` for milestones, and the inline label edit, the table cell, the item+modal and undo all go through one of those two. What that chokepoint gives, checked live: the+panel open on a row, Ctrl+Z, and the header and its title attribute follow the name back;+Ctrl+Shift+Z and they follow it forward.++Worth recording because it shaped the test rather than the code: the panel is never open+DURING a rename from the table or from an inline label edit. This app closes it whenever+attention moves off it, so switching lens closes it and so does the click on a label row that+starts an inline rename. Undo and redo are therefore the only paths that can change a name+while it is open, and they are what the suite exercises. The closing behaviour is pinned by an+assertion of its own so a later round cannot open one of the other paths without noticing that+the header is now on it.++The header is part of the drag surface: `wireEditorDrag` takes a mousedown on `.de-title` the+same way it takes one on `.drag-bar`, so there is still exactly one drag in this panel and+grabbing the name behaves the same as grabbing the grip, down to the clamp and the remembered+position. It is a div with no other handler, so a plain click on it does nothing at all: no+rename, no selection change, no second panel, checked for each of those.++The name is assigned rather than interpolated into the panel's markup, the way the item editor+already assigns its fields. A row named `<img src=x onerror=...>` arrives in the header and in+the title attribute character for character, becomes no element, and runs nothing.++The milestone panel deliberately does NOT get a header. The change asked for one "if it also+lacks the name", and it does not: the compact milestone panel has carried an editable `meName`+input since it was written, so a read-only copy above it would be exactly the duplicate this+change rules out. There is an assertion for that, so it stays a decision and breaks loudly if+the milestone panel ever loses that field.++**B. The armed dependency source uses the normal selection outline.**++0.39.0 gave the armed FIRST task of a pair its own two-band amber ring, and argued the case on+contrast: no flat colour clears 3:1 against both the lane fills and the page, so two bands.+Drew has overridden that, and the override is right for a reason the contrast argument never+addressed. Arming a task in dependency mode IS selecting it. The app already has one way to+say a bar is selected. A second vocabulary for the same idea costs a reader more than the+extra contrast buys them, and consistency wins.++So `.bar.dep-pending` joins `.bar.selected` in one rule rather than getting a copy of its+declarations, which is what makes "the same" enforceable instead of aspirational: the values+cannot drift apart in a later round because there is only one set of them. Verified as+computed style on the rendered bars, not read off the stylesheet: outline, colour, width,+offset and opacity all equal a normally-selected bar's, in both themes, and `box-shadow` is+`none` so the ring is genuinely gone rather than merely covered.++A bar that is both selected and armed now has nothing to reconcile, which is the pleasant part+of the answer. The state machinery needed nothing either: `setDepMode` already clears the+selection on entry and the arm on exit, and both were re-checked in both directions.++The amber keeps exactly the job it can still do alone. `--sig` and `--sig-edge` now belong to+the dependency ARROW: the stroke against the page and the halo under it for the stretches+where a route crosses a bar. The two-band arithmetic survives unchanged there, because an+arrow route genuinely does cross both lane fills and open page on its way across the chart,+which is the case the two bands were computed for. The distinction the change was careful not+to lose is intact and asserted: a picked arrow is amber, an arrow armed for delete is the red+this app deletes in, and neither is the selection accent, so "arrow selected", "arrow about to+go" and "bar selected" are three different things on screen.++`--sig-glow` is retired. It existed only to put a glow around the bar ring and had no other+reference, so it goes with the ring rather than sitting in three `:root` blocks as a token+nothing reads.++Milestones needed no equivalent treatment, and this is worth stating because the change asked+for one. `depIsEligible` has excluded milestones from dependencies since the mode was written,+so a milestone can never be the armed source: clicking one in the mode arms nothing and says+"milestones cannot be dependencies here". Asserted, so the claim is the app's and not a+reading of the code.++**Verification.** The standing battery is green on the same fixtures: v37 203, regress 26, abc+75, v38 249, v39 175, v40 138, and the five legacy suites TESTOUT OK with no JS errors. The+two fixture-coupled legacy FATALs, `mssel` and `msphase`, are byte-identical between 0.39.0+and 0.40.0.++v39 lost one assertion on the way, and deliberately. Four of its B1 checks required the amber+ring on the bar, which is the design decision Drew reversed; they are replaced by three that+require the armed bar to be visibly marked, marked in the selection accent, and free of the+ring, with a comment saying what superseded them and where the full treatment now lives. 176+to 175 is that trade, not a lost test.++Normal-mode selection is untouched, and that is a pixel claim rather than a promise: the same+selected bar was shot on both builds from the same fixture and every pixel outside a 27x27 box+is identical in both themes. That box is the row's edit pencil, which fades in on hover and is+caught mid-transition; the 0.40.0 build differs from ITSELF in the same box by the same 396+pixels, which is what identifies it as timing rather than as a change.+ ## Build notes, 2026-08-18 (0.39.0, the cap band gets its own space, dependency mode says what it is doing and can undo it, and one Tab crosses a date)  Four changes, all in the client. `server.js` is untouched, so the running server was never
gantt.html+88−38
@@ -42,19 +42,22 @@      so today reads as neutral grey and stops competing with both. */   --today: var(--text2);   --ok: #3fb950; --warn: #d29922;-  /* The dependency-mode signal. One colour, for two things and nothing else in this-     app: the armed FIRST task of a pair, and a picked dependency arrow. It is never a-     status, a phase, an accent or a warning, so seeing it means one thing.+  /* The dependency-mode signal, for ONE thing: a picked dependency ARROW. In 0.39.0 it+     also ringed the armed first task of a pair; 0.40.0 took that back, because an armed+     bar is a selected bar and the app already has one way to say a bar is selected. So+     the amber now means "this arrow", and the red next to it means "this arrow is about+     to go", and neither is ever a status, a phase or an accent. -     It is a RING of two bands rather than one flat colour, and that is arithmetic-     rather than taste. The three lane fills sit mid-luminance (teal 0.37, purple 0.23,-     blue 0.39 relative) with a near-black page behind them, so NO single colour can-     clear 3:1 against both: to beat the lightest fill it would have to be lighter than-     0.93, which is white, and to beat the page it would have to be far darker than-     that. So the band that touches the FILL and the band that touches the PAGE are-     chosen separately, each against what it actually abuts, and the pair reads as one-     amber ring. --sig-edge is the inner band, --sig the outer one. */-  --sig: #ffb02e; --sig-edge: #05080b; --sig-glow: rgba(255,176,46,0.30);+     It is two bands rather than one flat colour, and that is arithmetic rather than+     taste. An arrow route crosses lane fills AND open page on its way across the chart:+     the three lane fills sit mid-luminance (teal 0.37, purple 0.23, blue 0.39 relative)+     with a near-black page behind them, so NO single colour can clear 3:1 against both.+     To beat the lightest fill it would have to be lighter than 0.93, which is white, and+     to beat the page it would have to be far darker than that. So the stroke and the+     halo drawn under it are chosen separately, each against what it actually abuts:+     --sig is the stroke, which the page is measured against, and --sig-edge is the halo,+     which the fills are measured against. */+  --sig: #ffb02e; --sig-edge: #05080b;   --on-accent: #05221f;   --grid-line: rgba(48,54,61,0.18);   --weekend: rgba(255,255,255,0.022);@@ -65,8 +68,15 @@   --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;+     about how big the panel is when it clamps itself to the viewport.++     344 in 0.36.0, 370 since 0.40.0. The header that names the row costs the panel 26px+     of height (an 18px line, less its 2px lift, plus its 10px gap), and the dependency+     list is the only part that flexes, so at 344 the header would have been paid for out+     of the list: measured, it went from 60px to 34px, half a row shorter than 0.36.0+     sized it. The side grows by the same 26 instead, which gives the list back exactly+     what the header took AND keeps the panel square, since one token is both dimensions. */+  --ed-sq: 370px;   color-scheme: dark; } :root[data-theme="light"] {@@ -75,10 +85,10 @@   --text: #1f2328; --text-body: #24292f; --text2: #59636e; --text3: #8c959f;   --accent: #00857f; --accent-hover: #00a49d; --accent-bright: #00a49d; --today: var(--text2);   --ok: #1a7f37; --warn: #9a6700;-  /* Same ring, both bands flipped for paper: the inner band is the page white, which-     clears 3:1 against all three light fills, and the outer band is the deep amber,-     which clears 5:1 against the page. */-  --sig: #b45309; --sig-edge: #ffffff; --sig-glow: rgba(180,83,9,0.28);+  /* Same signal, both bands flipped for paper: the halo is the page white, which clears+     3:1 against all three light fills, and the stroke is the deep amber, which clears+     5:1 against the page. */+  --sig: #b45309; --sig-edge: #ffffff;   --on-accent: #ffffff;   --grid-line: rgba(31,35,40,0.10);   --weekend: rgba(31,35,40,0.04);@@ -95,7 +105,7 @@     --text:#1f2328; --text-body:#24292f; --text2:#59636e; --text3:#8c959f;     --accent:#00857f; --today:var(--text2); --grid-line:rgba(31,35,40,0.10); --weekend:rgba(31,35,40,0.04);     --ph1:#00a49d; --ph2:#8c6bf7; --ph3:#3b95ff;-    --sig:#b45309; --sig-edge:#ffffff; --sig-glow:rgba(180,83,9,0.28);+    --sig:#b45309; --sig-edge:#ffffff;     color-scheme: light; }   html, body { overflow: visible !important; height: auto !important; }   .labels-col, .timeline-col { overflow: visible !important; }@@ -282,15 +292,16 @@ body.col-resizing { cursor: col-resize; user-select: none; } .bar-row.dim { opacity: 0.15; } .bar { position: absolute; top: 7px; height: 20px; border-radius: 6px; opacity: var(--bar-opacity, 0.82); cursor: pointer; transition: opacity 0.15s; } .bar:hover { opacity: 1; }-.bar.selected { opacity: 1; outline: 2px solid var(--accent); outline-offset: 1px; }-/* The armed FIRST task of a dependency pair. Until 0.39.0 the class was set and there-   was no rule for it, so after the first click nothing on the chart said which task-   was armed and only the caption knew. Opacity goes back to 1 for the same reason-   .bar.selected does: opacity applies to the element's own shadow too, so a ring-   painted through 0.82 would arrive at less contrast than it was chosen for. Two-   bands, inner then outer, so the ring has 3:1 against the fill on one side and-   against the page on the other whatever lane the bar is in. */-.bar.dep-pending { opacity: 1; box-shadow: 0 0 0 2px var(--sig-edge), 0 0 0 4px var(--sig), 0 0 12px 4px var(--sig-glow); }+/* A selected bar, and the armed FIRST task of a dependency pair, are the SAME outline,+   deliberately, in one rule so they cannot drift apart. 0.39.0 gave the armed source+   its own two-band amber ring and 0.40.0 took it back: arming a task in dependency mode+   IS selecting it, the app already has one way to say a bar is selected, and a second+   vocabulary for the same idea costs more than the extra contrast it buys. A bar that+   is both selected and armed therefore has nothing to reconcile.++   Opacity goes back to 1 because the outline is drawn on the element, so an outline+   painted through 0.82 would arrive at less contrast than it was chosen for. */+.bar.selected, .bar.dep-pending { opacity: 1; outline: 2px solid var(--accent); outline-offset: 1px; } .bar-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 700; color: var(--on-accent); pointer-events: none; overflow: hidden; white-space: nowrap; padding: 0 6px; } :root[data-theme="dark"] .bar-note { color: rgba(6,20,24,0.82); } .bar-note-edit { position: absolute; inset: 0; width: 100%; height: 100%; background: var(--bg); border: 1px solid var(--accent); border-radius: 6px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 10px; text-align: center; outline: none; z-index: 20; }@@ -360,9 +371,11 @@ body.col-resizing { cursor: col-resize; user-select: none; } /* A PICKED dependency, in dependency mode only. The whole layer stays    pointer-events:none in every mode (see depEdgeAt: picking an arrow is a hit test    against the drawn route, not a hit target laid over the plan), so nothing here can-   take a click from the bars underneath. The drop-shadow is the inner band of the same-   two-band ring the armed bar wears: the stroke is --sig against the page, and the-   halo is --sig-edge for the stretches where a route crosses a bar. */+   take a click from the bars underneath. Two bands, for the reason set out at the token:+   the stroke is --sig against the page, and the drop-shadow halo is --sig-edge for the+   stretches where a route crosses a bar. Since 0.40.0 this is the ONLY thing wearing the+   signal colour, so amber on the chart means "arrow" and nothing else, and the red below+   is the one step further that means "arrow about to go". */ .dep-svg path.sel { stroke: var(--sig); stroke-width: 3; opacity: 1;   filter: drop-shadow(0 0 1.5px var(--sig-edge)) drop-shadow(0 0 1.5px var(--sig-edge)); } .dep-svg polygon.sel { fill: var(--sig); opacity: 1; }@@ -383,6 +396,22 @@ body.col-resizing { cursor: col-resize; user-select: none; } .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); }+/* The compact task panel has no name FIELD on purpose: the full Edit item modal owns the+   name and one editable name per row is enough. But until 0.40.0 that left it the one+   panel that never said WHICH task it was on, so the only way to be sure was to close it+   and hover the bar. The name goes at the top, read-only, in the same type the tooltip+   and the batch panel already use for the name of a thing.++   One line, ellipsised rather than wrapped, because the panel is a fixed square: a name+   that wrapped to two lines would shorten the dependency list under it by a row and the+   panel would change shape with the length of a name, which is the exact thing the square+   was for. The full name is on the title attribute, so the hover still has it.++   It sits directly under the grab strip and is PART of it: wireEditorDrag takes a+   mousedown here the same way it takes one on the strip, hence the grab cursor. It is a+   div, not an input, and carries no other handler, so a plain click on it does nothing. */+.editor .de-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 600; color: var(--accent); margin: -2px 0 10px; flex: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: grab; user-select: none; -webkit-user-select: none; }+.editor .de-title:active { cursor: grabbing; } .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@@ -4027,7 +4056,8 @@ 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-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:6px;"><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>`;+  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:6px;"><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.@@ -5424,7 +5454,7 @@ let edDrag=null, edManualPos=null; // 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;+  const sq=parseInt(getComputedStyle(document.documentElement).getPropertyValue('--ed-sq'),10)||370;   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@@ -5448,7 +5478,7 @@ function placeEditor(x,y){ // 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;+  return parseInt(getComputedStyle(document.documentElement).getPropertyValue('--ed-sq'),10)||370; } // 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@@ -5465,9 +5495,26 @@ let edSubject=null; // only at the release. Only the row the panel is actually editing, and only its dates: // a note or an owner typed in there is the user's and is never touched. Each field is // written only when it would change, so a caret sitting in one of them is left alone.+// The compact panel's header. The name is ASSIGNED rather than interpolated into the+// panel's markup, the way the full item editor assigns its fields, so a row named with+// a tag is a row named with a tag and never becomes one. The title attribute carries the+// same string, because the header is one ellipsised line and the hover is where the rest+// of a long name lives. Panels without a header (the milestone panel, which has a real+// name FIELD, and every non-square panel) have no #deTitle and this is a no-op for them.+function setEdTitle(r){+  const el=$('deTitle'); if(!el||!r)return;+  const n=r.name||r.id||'';+  if(el.textContent!==n)el.textContent=n;+  if(el.title!==n)el.title=n;+} function syncEditorDates(r){   if(!r||!edSubject||edSubject!==r)return;   if(!editorEl.classList.contains('show'))return;+  // The header names the row, so a rename that lands while the panel is open has to+  // reach it too. Same chokepoint as the dates, for the same reason: every path that+  // changes a row already comes through here, whether it is a drag, an undo, the other+  // lens's table cell or an inline label edit.+  setEdTitle(r);   const set=(id,v)=>{const el=$(id);if(el&&v&&el.value!==v)el.value=v;};   if(r.type==='milestone'){ set('meDate',r.date); set('imEnd',r.date); return; }   set('deStart',r.start); set('deEnd',r.end);@@ -5523,10 +5570,13 @@ function wireEditorTab(){ } 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;+  // Only the grab strip and the header under it start 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.+  // The header is in here rather than carrying its own handler so there is still exactly+  // one drag in this panel, and grabbing the name behaves the same as grabbing the grip+  // down to the clamp and the remembered position.+  if(!e.target.closest('.drag-bar,.de-title'))return;   e.preventDefault();   const rect=editorEl.getBoundingClientRect();   edDrag={sx:e.clientX-rect.left,sy:e.clientY-rect.top,moved:false};
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.39.0",+  "version": "0.40.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+2−2
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.39.0",+  "version": "0.40.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": {@@ -63,4 +63,4 @@     "name": "Drew Owens",     "email": "[email protected]"   }-}\ No newline at end of file+}