← Commit history

Publish 0.52.0

Drew Owens ·367f552e93 · ·parent 4013b5a
7 files changed +730−63
README.md+10−4
@@ -10,13 +10,15 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau  A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependency that crosses sections is not an edge and never lights up, and a section with no dependencies among its own rows lights its longest single row. Ties are broken deterministically: more working days, then a greater calendar span, then more rows, then whichever starts earlier in the plan. Everything else stays exactly as it was, selection and dependency mode included, the glow recomputes as you edit, and the state is remembered across reloads. -**Analytics.** A third lens, next to Gantt and Table. Total effort for the plan in hours (working days times eight, a forty-hour week), the working hours LEFT between today and the project end, the AVERAGE hours per assigned person, then one row per person carrying their hours, share and item counts alongside their four status counts, the unassigned bucket included. Status counts overall, per-section totals with the range each section actually covers, milestones with days from today, dependency stats with a cycle warning, and everything overdue. Read-only, whole-plan, and re-derived from the same rows every time you switch to it.+**Analytics.** A third lens, next to Gantt and Table. Total effort for the plan in hours (working days times eight, a forty-hour week), the working hours LEFT between today and the project end, the AVERAGE hours per assigned person, then one row per person carrying their hours, share and item counts alongside their five status counts, the unassigned bucket included. Status counts overall, per-section totals with the range each section actually covers, milestones with days from today, dependency stats with a cycle warning, and everything overdue. Read-only, whole-plan, and re-derived from the same rows every time you switch to it.  **Three lenses over one plan.** A **Gantt / Table / Analytics** toggle in the header. The chart answers "when". The table answers "whose, and by when": every task and every milestone as a row with Owner, Start, Deadline, Status and Notes, sortable on any column, filtered to one person with the owner dropdown, and editable in place. An edit made in the table is already true on the chart when you toggle back, because there is only one set of items underneath. The choice of lens persists, and `project.view.mode` can pin it for a plan that is an assignment list more than a timeline.  **Add rows.** The New button creates a task or a milestone: name, lane, owner, dates, status, notes and provenance. Created rows live in the sidecar, so the data file is never rewritten, and they appear in every export. -**Four statuses.** Not started, in progress, blocked, complete. Plans written against the older "live" and "planned" still read correctly without being changed.+**Five statuses.** Not started, in progress, blocked, complete, cancelled. Plans written against the older "live" and "planned" still read correctly without being changed, and so do the American "canceled" and a tracker's "won't do".++**Cancelled is work that will not happen**, which is a different thing from work that is finished. The row stays on the plan, because deleting it would lose why it was ever there and what used to depend on it, but its HOURS leave every total: the project total, the owner's load, the section's effort and every percentage computed from them. Its own numbers are untouched, so the bar, the tooltip, the export and Ctrl+Z all still say what they said. It still counts as an item and it still has a column on the status cards, because it is a decision somebody made and worth seeing. It is never overdue. On the chart the bar is dimmed and the row is muted, well short of the fade a filtered-out row gets, and the pill carries a line through it.  **Edit any row.** Hover a row in the chart's label column and an Edit button appears at its right edge: one click opens the full editor for every field. Single-clicking the name still edits it in place. @@ -38,12 +40,16 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc  **Click-to-edit.** Click any bar to open an inline editor with date pickers and dependency management. Drag bar edges to resize start/end dates. Drag bar body to slide dates while preserving duration. -**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.+**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 / cancelled). 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.+**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. The list scrolls when a plan has more people than it can show, by the wheel, the trackpad or the scrollbar; Escape closes it, so does a click anywhere outside it, and so does scrolling the plan out from under it.  **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. +**Dependency arrows are coloured by where they come FROM.** Each arrow takes the phase colour of the task at its tail, so a chain's origin is readable at a glance instead of every arrow being the same teal. A lane colour chosen to work as a wide bar can be invisible as a thin line, so the stroke is derived rather than copied: it is lifted or deepened away from the page, holding its hue, until it clears a 3:1 contrast ratio, which means a lane painted navy gets a lighter blue arrow that still reads as that lane. Picking an arrow in dependency mode, arming one for deletion and lighting the critical path all still override the colour while they are active.++**The full item editor is the New panel.** Editing a task or a milestone opens the same layout, in the same order, with the same labels, as creating one: type and lane, name, owner and status, the dates, notes and provenance. Type is shown but not editable on an existing row, because a task has two dates and a bar and a milestone has one date and a diamond, and dropping one of a task's dates is not a conversion. The buttons are Apply, Cancel and Delete, with Delete kept away from the other two.+ **The task editor remembers where you put it.** The panel that opens on a bar is a compact 256px square, draggable by the strip at its top. It is meant to be glanced at rather than filled in: the row's name across the top, the two dates, the note, the dependency list (the one part that flexes, and the one part that scrolls) and the buttons, at desktop density, so it sits over as little of the chart it is editing as it can. The full **Edit item** panel, with the name, owner, status and provenance fields, is the one you fill in and it is unchanged. 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. Its **Delete** removes the whole selection, milestones included, in one press and one Ctrl+Z: the same path the chart's armed Delete key uses, so it is one undo entry, one changelog batch, dependencies on the departed rows dropped and their ids retired either way.
SKILL.md+17−4
@@ -167,7 +167,7 @@ items. | phase | number | yes | Index into phases[] | | owner | string | no | The one person accountable. A single name, never a team. Drives the table's owner filter | | provenance | object or string | no | Where this row came from. `{"label":"POR","url":"https://..."}` or just `"Drew added this"`. Only http and https urls render as links; anything else shows as text |-| status | string | no | One of: "not-started", "in-progress", "blocked", "complete". The old "live" and "planned" are still read and shown as in progress / not started |+| status | string | no | One of: "not-started", "in-progress", "blocked", "complete", "cancelled". The old "live" and "planned" are still read and shown as in progress / not started, and "canceled", "won't do", "abandoned" and "dropped" all read as cancelled | | description | string | no | Tooltip body text, and the Notes column in the table | | deps | string[] | no | Array of task IDs this depends on | | note | string | no | Short label drawn on the bar itself, e.g. `6m` or `Y1`. Keep it to a few characters |@@ -234,6 +234,7 @@ rather than a category. - **The batch panel's Delete** removes the WHOLE selection, milestones included, in one press. It is the compact panels' danger button in the compact panels' place: a muted ghost, right-aligned by `margin-left:auto` so it is clear of Apply and Cancel, red on hover, last in the panel's Tab walk. It lands on the same `deleteRows` the chart's armed Delete key and the table's Delete use, so it is one `multi` undo entry restoring every row where it stood, one changelog batch, dependencies on the departed rows dropped and their uids retired, whichever hand did it; only the `via` differs (`batch-delete` against `chart-delete`). One press and no arming, unlike the key: the key acts on a selection that may be entirely off screen and asks twice, where a button inside the panel that IS the selection made visible has already been aimed. The panel's headline counts the TASKS the date fields will move and says how many milestones it is leaving alone; the Delete's tooltip names the total it will actually remove, so the two counts cannot be read as one - **Copy IDs**: with rows selected, the table's tools row and the chart's selection chip (and the batch panel) copy the selected items' stable ids, one per line as `id<TAB>name`, for pasting into an AI chat to request batch edits. Ctrl+C does the same when nothing is focused and no text is selected. If the clipboard is refused the ids appear in a selectable box rather than failing silently - **Row multi-select in the table**: ctrl-click toggles a row, shift-click takes the range from the anchor following the VISIBLE order under sorts and filters. It is the SAME selection as the chart's, so rows picked in one lens are selected in the other. Modified clicks never open a cell editor and never start a native text selection; unmodified click-drag still selects cell text. Alt+Up/Down moves the whole set (plan order only; refused under a sort), and Delete removes the whole set as one undo. A selection with GAPS in it groups itself up on the first Alt+Arrow press rather than moving: the selected rows close up into one contiguous block at the topmost one's slot+- **Arrows are coloured by where they come FROM.** Each dependency arrow, line and head together, takes the phase colour of the task at its TAIL, so the origin of a chain is readable without tracing it back and two arrows into the same row say which lane each came out of. A lane colour is chosen to work as a 20px bar and can vanish as a 1.5px line, so the stroke is derived rather than copied: hue and saturation are held and lightness is walked away from the page in 2% steps until the stroke clears a 3:1 contrast ratio against the background, which is the WCAG minimum for a graphical object. A lane painted navy therefore gets a lighter blue arrow on the dark theme and is left alone on the light one, and it still reads as that lane rather than reverting to a neutral. Recomputed on a theme change, a lane repaint and a row moving lane. The amber of a picked arrow, the red of one armed for deletion and the white of the critical path all still override it while they are active - **Arrows steer clear of the bars.** A dependency leaves the source bar's END sideways, drops, and arrives at the next bar's START sideways again, with rounded elbows, so which end is the cause is always readable. It routes AROUND the bars in between rather than through them: the drop moves to the nearest clear channel, and a long or backward route runs along the empty lane between two rows rather than across a task that has nothing to do with it. Where a bar lies clean across the only gap, the arrow goes round the end of it. Nothing to configure; it is re-decided on every redraw, including live during a drag - **Deps column** in the table: a narrow read-only indicator, needs and blocks counts with the names on hover, blank when there are none. Clicking it opens the editor where dependencies are changed. Sortable by total count - **Ctrl+Z** to undo (100-level stack)@@ -285,6 +286,10 @@ named in its own column.  **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. +**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.++**Popup lists scroll.** The owner list, the status list and the table's select cells are the app's own lists rather than native popups. They can be scrolled by wheel, trackpad or scrollbar without closing, which matters as soon as a plan has more people than a list can show. Escape closes them, a click genuinely outside closes them, choosing an item commits and closes, and the plan scrolling out from under an open list still closes it, because a list left hanging over a cell that has moved is worse than one that went away.+ **Reordering.** Click and hold a row (about 350ms) to lift it, drag, drop. Only available in plan order, since a sorted view is a lens and not the plan. Dropping into another lane sets phase and position as one undo. On the chart, dragging a group header moves that whole lane.  **Dates** display as `17-Aug-2026`. Stored values (data file, sidecar, changelog payloads, CSV) stay ISO `yyyy-mm-dd`.@@ -329,12 +334,20 @@ Seven cards: | Card | What it answers | |------|-----------------| | Project total | Total effort, WORKING HOURS LEFT to the project end, AVERAGE per person assigned, working days in the plan's span, dated tasks, milestones, people |-| Per person | One row per owner, unassigned included: hours, share of the total and item counts FIRST, then the four status counts. Sorted by hours |-| Status | The four status counts over every task and milestone, with shares |+| Per person | One row per owner, unassigned included: hours, share of the total and item counts FIRST, then the five status counts. Sorted by hours |+| Status | The five status counts over every task and milestone, with shares | | Per section | Items, hours and the date range each section actually covers, derived from its rows | | Milestones | Every dated milestone with its date, days from today and owner | | Dependencies | Link count, rows in the graph, rows outside it, and a cycle warning naming the rows |-| Overdue | Every row with a deadline in the past and a status that is not complete |+| Overdue | Every row with a deadline in the past that is neither complete nor cancelled |++**Cancelled work carries no hours.** A task with status `cancelled` is out of the project+total, out of its owner's load, out of its section's effort, and out of every percentage+computed from any of those, which means cancelling work raises everybody else's share of+what is left. It is NOT out of the counts: it is still an item against its owner and it+has its own column on both status cards, because it is a decision somebody made. It is+never overdue. The row's own hours are untouched, so the bar, the tooltip, the exports+and Ctrl+Z all still read the same, and un-cancelling it puts the hours straight back.  **Working hours left** is calendar capacity rather than a task sum: the working days from today to the last dated row in the plan, at 8h each. Today counts when today is itself a
docs/BUILD-NOTES.md+276
@@ -3,6 +3,282 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-21 (0.52.0, a fifth status and three things that were wrong)++Four changes, one round. Two of them are features Drew asked for, one is a readability+change to the chart, and one is a bug he screenshotted. Both files moved: `gantt.html`+for all four, `server.js` for the first.++### 1. CANCELLED, a fifth task status++The vocabulary was four states since 0.31.0: not-started, in-progress, blocked,+complete. It is five now. Cancelled is not a synonym for complete, and the difference is+the whole reason to have it: complete is work that happened, cancelled is work that will+not. The plan keeps the row either way, because deleting it would lose why it was ever+there and what used to depend on it.++**Both sides, in lockstep.** `STATUS_ORDER` exists twice, once in `gantt.html` and once+in `server.js`, and that duplication is deliberate: the server normalises on load, on+import and on save, so a fifth state added on one side only would be half-supported in+silence. Both lists changed together, and both comments now say so. `STATUS_ALIAS` picked+up the American spelling and the phrasings a tracker exports it as: `canceled`, `cancel`,+`abandoned`, `won't do`, `wont do`, `wontdo`, `dropped`.++**Where the hours stop.** A cancelled task's hours are out of every hours figure on the+analytics lens. The gate is `effortHoursOf(r)`, and it is applied at the three+ACCUMULATORS and nowhere else:++* `totalHours`, the plan total and the divisor for every per-person share+* the per-owner `b.hours` bucket+* the per-section `hours` reduce++It is deliberately NOT inside `taskHours`. The row's own hours are still its hours: the+bar, the tooltip, the undo snapshot, the CSV export and the JSON export all go on saying+exactly what they said before, and it is only the sums that stop including it. That is+what keeps a cancel reversible without a special case anywhere.++Because `totalHours` is the divisor, cancelling work makes everybody else's percentage go+UP. That is intended and it is the true reading: nobody took on more work, but their+share of what is left to do really did grow.++**Where the counts do not stop.** Status COUNTS include cancelled. A cancelled row is a+real decision somebody made, and a card that hid it would be hiding the answer to "what+happened to that". The status card grew a fifth row and the merged per-person card grew a+fifth column; both are driven off `STATUS_ORDER` and inherited the column for free.++**Overdue.** Was a single `!== 'complete'` compare. It is a set now, `SETTLED`, holding+complete and cancelled: complete means it happened, cancelled means it was called off,+and neither is late. Everything else, a blank status included, is still owed.++**How it is drawn.** The pill sits in the muted family beside not-started, told apart by+a line through it rather than by a fifth hue: another colour in the tag row would be one+more thing to learn, and a strikethrough already means "this is off" to everybody. The+bar's opacity is `calc(var(--bar-opacity, 0.82) * 0.42)`, a FRACTION of the theme's own+bar opacity rather than a fixed number, which keeps the two themes at the same relative+step: dark bars land at 0.34 and light bars, which are opaque on white and start with+less headroom, land at 0.42. The label row and the table row sit at 0.62, well above the+bar, because the promise is that the name stays readable. It is deliberately nowhere near+the 0.15 the FILTER dim uses: a filtered row is context you are looking past, a cancelled+row is part of the plan you are reading.++**Two import maps corrected on the way through.** The JIRA map wrote the PRE-0.31+vocabulary, so `done` mapped to `live` which `statusKey` then aliased to `in-progress`: a+finished JIRA issue imported as work in progress. `in progress` mapped to `planned` and+came in as not started. Both are now written in the current vocabulary, and the map picked+up the six ways JIRA spells cancelled. The MS Project importer had the same pre-0.31+problem and got the same correction; it never produces cancelled, because no percentage+complete means "called off". No suite covered either importer, which is why this had+survived. The legacy SVG renderer's status legend was keyed on the old words too and now+carries both.++### 2. The edit panel is the new-item panel++They were two layouts doing one job. The create panel led with type and phase and put the+name under them; the edit panel led with the name and put phase alone on a row of its+own, with different placeholders, a hint on one and not the other, and the start field+REMOVED for a milestone where the create panel merely hides it. Somebody who had just+created three rows had to re-find every field the moment they went to edit one.++The create panel is the one that stayed, because it is the one people meet first. The+edit panel now matches it row for row, field for field, label for label, placeholder for+placeholder, hint for hint, in both themes.++Three differences remain, and each is a place where editing an existing row is genuinely+not the same job as making a new one:++* **Type is present but DISABLED**, with a tooltip that says why. Present, because+  removing it would put every row below it one line higher and the two panels would not+  match. Disabled, because nothing in this app converts a task into a milestone: a task+  has a start and an end and a bar, a milestone has one date and a diamond, and silently+  dropping one of a task's two dates is not a conversion. Disabled rather than hidden also+  means the panel still SAYS what the row is. A disabled control is skipped by Tab, which+  is correct: you should not be able to Tab onto something you cannot use, and that one+  difference in the Tab walk is the point of disabling it rather than a cost of it.+* **Status keeps its "none" option.** The create panel has no need of one, since a new row+  opens on not-started. An existing row can have no status at all and there has to be a+  way back to that, or the first person to set one by accident could never clear it.+* **The buttons are Apply / Cancel / Delete** rather than Create / Cancel, with Delete+  holding the `margin-left:auto` that has kept the destructive button away from the other+  two since 0.37.0.++The compact 256px panels were out of scope and did not move.++### 3. Dependency arrows take the SOURCE row's phase colour++Every arrow was the same teal. On a plan with forty of them that said only "there is a+dependency here" and made the layer a thicket. Each arrow is now painted in the phase+colour of the row it comes OUT of, so where a chain starts is readable without tracing it+back. The tail and not the head, deliberately: an arrow answers "what is this waiting+on", and the answer is the thing at the tail.++**The contrast formula, stated once.** A phase colour was chosen to be a 20px-tall FILL. A+1.5px stroke has about a twentieth of the area, so a colour that reads perfectly as a bar+can disappear as a line. The three ramp colours are safe by construction because they are+picked against the page in each theme. An EXPLICIT lane colour is not: it is+theme-invariant by design (0.30.0), so somebody who paints a lane navy has painted an+invisible arrow on the dark theme without meaning to. So the stroke is DERIVED:++1. resolve the phase colour to r,g,b through the same `cssRgbTriple` the hover wash uses,+   so a `var(--phN)` and an explicit hex both come out as three numbers+2. take the WCAG relative luminance of it and of the page, `--bg`+3. if the contrast ratio is at or above FLOOR, use the colour unchanged+4. otherwise hold HUE and SATURATION and walk LIGHTNESS away from the page in 2% steps,+   re-measuring each step, until the ratio clears the floor or lightness runs out++FLOOR is 3.0, the WCAG 2.1 minimum for a graphical object, which is what a line is. Text+would be 4.5 and would push half the ramp to pastel for no gain; the arrow only has to be+findable, and the name beside it carries the meaning. Holding hue makes step 4 a repair+and not a substitution: a navy lane gets a lighter blue arrow still recognisably that+lane, rather than falling back to the one teal that told you nothing. Walking AWAY from+the page means the theme decides the direction, so the same navy is lifted on dark and+left alone on paper. Memoised per colour per theme.++Measured on the 0.52.0 bench, dark page `#0d1117`: ramp teal 7.65, explicit `#4ea1ff`+7.09, explicit navy `#1b2a4a` 1.55 unaided and 3.16 after the lift. Light page `#ffffff`:+ramp teal 3.09, `#4ea1ff` darkened to 3.01, navy 14.22 untouched.++**Precedence.** The colour is written as a CSS custom property, `--dep-stroke`, and NOT as+an inline `stroke`. An inline stroke would beat every class on the element, and the three+states that override the base colour are all classes: the picked arrow's amber (0.39.0),+the armed-delete red, and the critical path's white (0.42.0 / 0.49.0). Setting a property+leaves the base rule to read it and leaves each of those rules to win over the base rule+exactly as before. The head is a polygon carrying the same property, so the line and the+head it ends in are one colour and cannot drift.++**Not marker defs.** The brief asked for one SVG marker per colour. The layer does not use+markers: since 0.19.0 each edge is one `path` plus one `polygon`, and every other reader+of the layer counts on that shape, `paintDepSel` and `paintCritDeps` included. The polygon+IS the per-edge arrowhead and takes a per-colour fill directly, so a marker registry would+have added a def table and a DOM change for no visual difference. The requirement behind+it, that no colour is hardcoded and each edge gets its own, is met.++**Milestone-source arrows are unreachable today.** `barEls` holds task bars only, so+`drawDeps` can neither start nor end an arrow at a milestone. The code takes the source+ROW's phase whatever type that row is, so it will do the right thing the day milestones+become linkable, but there is nothing to verify this round. Making them linkable is a+feature and not a paint change, so it stayed out.++**Live recolour.** Every recolour path but one ends in `refreshChartGeometry`, which+redraws the layer and repaints it on the way past. The two that do not move any geometry+had to say so themselves: `setPhaseColor` (repainting a lane) and the theme toggle (which+changes both the ramp and the page the stroke is measured against). Both call+`recolorDepArrows`, a walk of the paths already in the layer setting one property each.++**Paint only.** Every `d` attribute, every arrowhead `points` string and the layer's own+width and height are byte-identical to 0.51.0, recorded across load, Fit, dependency mode,+critical path, light theme and back, on the 0.52.0 bench AND on the 0.41.0 obstacle bench+whose whole purpose is routes that dodge bars.++### 4. A dropdown that survived being scrolled++Drew's screenshot: the owner pill's list disappears the moment he tries to scroll it. The+guess in the brief was the click-away handler firing on a scrollbar mousedown. It was not.+There were TWO defects, and the click-away handler was innocent in both.++**Defect one, which is the one he saw.** Every list of ours is a fixed-position box+anchored to something the page can scroll out from under it, so all of them close when the+page scrolls; that is why the listener is `scroll` in the CAPTURE phase, which is the only+way to hear an inner pane scroll at all, since scroll does not bubble. But a capture+listener on window hears a scroll of EVERY element on the page, and the list itself is one+of them. `.cell-list` is 232px tall with `overflow-y:auto`, so reaching the tenth name in+it means scrolling it, and scrolling it dismissed it. Reproduced on 0.51.0 before the fix:+one wheel notch over the list and it was gone.++The guard is the event's TARGET, at the shared chokepoint `popupScrollGuard`: a scroll+that started inside a list of ours is that list scrolling itself, which is somebody+reading it and never a reason to take it away. A scroll anywhere else is the page moving+under the anchor, which still is. Both list families go through it, so the owner pill, the+status pill and every select cell in the table are covered by the one guard. The page+scrolling under an open list still dismisses it, and that is verified: losing it would be+a worse bug than the one being fixed.++**Defect two, which is why the scrollbar felt dead.** Both lists cancel mousedown, for two+good reasons (never steal focus from the offscreen select, never let the native select+popup open on top of ours), but they cancelled it everywhere on the element including its+own scrollbar, and `preventDefault` on a scrollbar mousedown cancels the browser's native+drag. So the bar could be grabbed and would not move. `onOwnScrollbar` now excepts it, and+handles both kinds of scrollbar: a CLASSIC one (Chrome on Windows and most Linux desktops,+which is where Drew is) takes layout width and `offsetX >= clientWidth` names it exactly,+while an OVERLAY one (macOS, and headless Chrome) takes no layout width, leaving only the+band at the edge it is painted over.++That second half is not testable headlessly. Headless Chrome draws overlay scrollbars and+CDP cannot drive a native scrollbar drag on ANY page, ours or a bare one; that was+established with a control test on a plain div before concluding anything about our code.+So the suite asserts the DECISION the app makes about that mousedown, which is the defect:+a press on the content is still cancelled, a press on the scrollbar is left alone.++**Two things found next door and fixed.**++* The owner list had no Escape. It carried one on its type-in FIELD only, so the key+  worked if you had clicked into the field and did nothing at all if you had just opened+  the list and changed your mind, which is the common case. It now has the same+  document-level capture handler the status list beside it has always had. This was true+  on 0.51.0 too; it is not a regression this round opened.+* `openStatusPill` and `openOwnerPill` both named their Escape handler `esc`, which is+  also the app's global HTML-escape helper. The local shadows the global for the WHOLE+  function, which put the `esc(cur)` two lines above it into the temporal dead zone. In+  `openStatusPill` that throw only fires for a row carrying a status word none of ours+  match, which is why nobody had met it. Both are renamed `escKey`.++### Found and fixed while verifying: the panel keydown listeners piled up++Both panels added a keydown listener to `.editor` on every open. `.editor` is permanent+and only its innerHTML is replaced, so the listeners accumulated: after three trips+through the edit panel, an Enter pressed in the CREATE panel ran three stale edit-panel+handlers, each reaching for an Apply button no longer on the page and each throwing+`Cannot read properties of null`. Confirmed on 0.51.0, one error per stale listener.+`setEditorKeys` now swaps rather than adds, so exactly one is live and it always belongs+to the panel that is mounted. `wireEditorTab`'s listener is not one of these: it is wired+once at start-up, reads only the focused element, and outlives every panel on purpose.++### Open question, not decided: cancelled and SCHEDULING++Auto-schedule and the critical path treat a cancelled task exactly as they treated it+before: it is still a node in the graph, it still consumes calendar, and it can still sit+on the critical path. That is untouched on purpose. There is a real argument that+cancelled work should be transparent to the scheduler, so that a chain running through a+cancelled task closes up rather than waiting on work nobody is going to do, and an equally+real argument that a plan whose rows silently stop constraining each other is harder to+trust than one that does not. Drew has not decided, and changing scheduling semantics+quietly is not something to do on the way past. It is a round of its own if he wants it.++### A note on the live server++`server.js` changed this round. The client is read from disk per request, so a client+change is live on the next reload; the SERVER is loaded once at start-up. The instance on+8901 goes on running 0.51.0's server code until it is next restarted for its own reasons,+which is exactly the behaviour every server round has had. Nothing about this round needs+that restart, and that is not an assumption. The exact pair it will be running, a 0.51.0+server process serving the 0.52.0 client off disk, was stood up on a bench of its own and+driven: setting a status to cancelled through the new client persisted to the old server's+sidecar as `cancelled`, survived a reload, and came back out of the old server's CSV export+as `cancelled`. It works because of the pass-unknown-through line in `statusKey` that has+been there since 0.31.0: a word the old server does not know survives as itself rather than+being flattened into one it does. The only server-side changes this round are the status+vocabulary and the two import maps, so a plan saved by the old process is read correctly by+the new one and the other way round.++### What the suites say++The full standing battery is green. Two suites carried assertions on the FOUR-status+vocabulary and were updated deliberately, with the reason recorded at each one:++* `v42.mjs`, three assertions: the per-person card's column list, its hand-computed rows,+  and the status card's rows. Every cancelled cell on that plan is a zero, so the column's+  presence is the change and the numbers beside it are the proof nothing else moved.+* `v45.mjs`, ten assertions: the same column list, five hand-computed rows, the+  independent second model of the plan, the column-sum check, and the nine-columns+  measurement in both themes, now ten.++No assertion about arrow colour or edit-panel layout existed to update; those are new+ground and `v52.mjs` covers them.++`v52.mjs` is 137 checks over the four changes and passes with zero JS errors. The+`mssel` and `msphase` A/B against 0.51.0 is byte-identical, and so is the routing+recording described above.++ ## Build notes, 2026-08-21 (0.51.0, the agent comes through the door)  One change, server-side. `server.js` only. `gantt.html` did not move a byte this round:
gantt.html+380−39
@@ -440,13 +440,34 @@ body.col-resizing { cursor: col-resize; user-select: none; } .tag-in-progress { background: rgba(0,184,177,0.14); color: var(--accent); } .tag-blocked { background: rgba(210,153,34,0.15); color: var(--warn); } .tag-complete { background: rgba(63,185,80,0.14); color: var(--ok); }-/* A status that came in from a CSV and is none of the four renders as itself,+/* Cancelled sits in the muted family beside not-started, because both are "no work is+   happening here" and neither should pull the eye the way blocked and complete do. It+   is told apart from not-started by the line through it rather than by a colour of its+   own: a fifth hue in the tag row would be one more thing to learn, and a strikethrough+   already means "this is off" to everybody. Same two tokens in both themes, so the pair+   stays a pair on paper. */+.tag-cancelled { background: rgba(139,148,158,0.10); color: var(--text3); text-decoration: line-through; text-decoration-thickness: 1px; }+/* A status that came in from a CSV and is none of the five renders as itself,    quietly. Better than pretending it is one of ours or dropping it. */ .tag-other { background: rgba(139,148,158,0.10); color: var(--text2); font-style: italic; }  .bar-row { height: 34px; position: relative; border-bottom: 1px solid rgba(48,54,61,0.15); transition: opacity 0.15s; } .bar-row.group { height: 28px; background: var(--surface); } .bar-row.dim { opacity: 0.15; }+/* CANCELLED, on the chart. The filter dim is 0.15 because a filtered-out row is+   context you are looking past; a cancelled row is part of the plan you are reading,+   so it is quieted and not nearly erased. The two are deliberately different numbers+   and never the same one.+   The bar's opacity is a FRACTION of the theme's own bar opacity rather than a fixed+   number, which is what keeps the two themes at the same relative step: dark bars start+   at 0.82 and land at 0.34, light bars are opaque on white and land at 0.42, and a light+   bar needs that extra because it has white underneath it instead of near-black.+   The LABEL row stays at 0.62, well above the bar, because the promise is that the name+   is still readable: quieting a row must never cost you the ability to find it. */+.bar.cancelled { opacity: calc(var(--bar-opacity, 0.82) * 0.42); }+.ms-diamond.cancelled, .ms-line.cancelled { opacity: 0.45; }+.label-row.cancelled { opacity: 0.62; }+.data-table tr.cancelled { opacity: 0.62; } .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; } /* A selected bar, and the armed FIRST task of a dependency pair, are the SAME outline,@@ -522,8 +543,13 @@ body.col-resizing { cursor: col-resize; user-select: none; } .today-tag { position: absolute; top: calc(var(--group-h) + 4px); transform: translateX(-50%); font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--today); background: var(--bg); padding: 2px 6px; border-radius: 3px; border: 1px solid var(--border); pointer-events: auto; cursor: default; z-index: 9; white-space: nowrap; } .today-tag:hover { border-color: var(--today); } .dep-svg { position: absolute; top: 0; left: 0; pointer-events: none; z-index: 4; overflow: visible; }-.dep-svg path { fill: none; stroke: var(--accent); stroke-width: 1.5; opacity: 0.5; transition: opacity 0.15s; }-.dep-svg polygon { fill: var(--accent); opacity: 0.5; transition: opacity 0.15s; }+/* --dep-stroke is set per edge from the SOURCE row's phase, contrast-corrected against+   the page (see depStroke). The fallback is the accent, which is what every arrow used+   to be, so an edge whose colour could not be resolved looks exactly like 0.51.0 rather+   than looking broken. Read through a variable rather than written as an inline stroke+   on purpose: the three states below have to keep beating it. */+.dep-svg path { fill: none; stroke: var(--dep-stroke, var(--accent)); stroke-width: 1.5; opacity: 0.5; transition: opacity 0.15s; }+.dep-svg polygon { fill: var(--dep-stroke, var(--accent)); opacity: 0.5; transition: opacity 0.15s; } .dep-svg.hidden path, .dep-svg.hidden polygon { opacity: 0; } /* == The critical path ==    The longest run of work in the plan, breathing. Everything here is ADDITIVE: the glow is@@ -1247,6 +1273,7 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va         <option value="in-progress">in progress</option>         <option value="blocked">blocked</option>         <option value="complete">complete</option>+        <option value="cancelled">cancelled</option>       </select>       <input type="search" id="tblSearch" placeholder="Filter by text" aria-label="Filter rows by text">       <button class="tt-btn" id="tblClear" title="Clear every filter">Clear</button>@@ -1335,14 +1362,24 @@ function rowOrderKey(r){ return r.uid || r.id || r._key; } const deletedKeys = new Set();  -// Four states. The old vocabulary is aliased on READ, everywhere, so stored data+// Five states. The old vocabulary is aliased on READ, everywhere, so stored data // (the data file, an example, a sidecar the user is mid-edit in) keeps working // untouched and only starts writing the new words when it is next saved.-const STATUS_ORDER=['not-started','in-progress','blocked','complete'];+//+// CANCELLED is last on purpose, and it is not a synonym for complete. Complete is+// work that happened; cancelled is work that will not. The plan keeps the row,+// because deleting it would lose why it was ever there and what depended on it, but+// its HOURS stop counting: a cancelled task is not effort anybody owes. Its own+// dates and its own hours are left intact on the row, so the display, the undo and+// the export are unchanged and it is only the analytics accumulators that skip it.+const STATUS_ORDER=['not-started','in-progress','blocked','complete','cancelled']; const STATUS_ALIAS={   'live':'in-progress','in progress':'in-progress','in_progress':'in-progress',   'planned':'not-started','not started':'not-started','notstarted':'not-started',   'done':'complete','completed':'complete',+  // American spelling, and the two phrasings a tracker exports it as.+  'canceled':'cancelled','cancel':'cancelled','abandoned':'cancelled',+  "won't do":'cancelled','wont do':'cancelled','wontdo':'cancelled','dropped':'cancelled', }; function statusKey(v){   const t=String(v==null?'':v).trim().toLowerCase();@@ -1351,8 +1388,13 @@ function statusKey(v){   return STATUS_ALIAS[t]||t;      // unknown values survive as themselves } function statusOf(r){ return statusKey(r&&r.status); }-const statusTag={'not-started':'tag-not-started','in-progress':'tag-in-progress',blocked:'tag-blocked',complete:'tag-complete'};-const statusLabel={'not-started':'not started','in-progress':'in progress',blocked:'blocked',complete:'complete'};+const statusTag={'not-started':'tag-not-started','in-progress':'tag-in-progress',blocked:'tag-blocked',complete:'tag-complete',cancelled:'tag-cancelled'};+const statusLabel={'not-started':'not started','in-progress':'in progress',blocked:'blocked',complete:'complete',cancelled:'cancelled'};+// Work that will not happen. Asked everywhere a number is being added up, so the one+// rule lives in one place: a cancelled row keeps its dates and its own hours and is+// simply not counted into anybody's total.+function isCancelled(r){ return statusOf(r)==='cancelled'; }+function effortHoursOf(r){ return isCancelled(r)?0:taskHours(r); } function statusTagFor(k){ return statusTag[k]||'tag-other'; } function statusTextFor(k){ return statusLabel[k]||k; } // Rows the user created. The app never rewrites the data file, so a new row has@@ -3520,6 +3562,11 @@ function buildRowDom(r,idx){     const nt=document.createElement('div');nt.className='bar-note';nt.textContent=r.note||'';b.appendChild(nt);r._noteEl=nt;     b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle left'}));b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle right'}));bDiv.appendChild(b);barEls[r.id]={bar:b,idx,row:bDiv};}   r._lDiv=lDiv;r._bDiv=bDiv;r._itemIdx=idx;labelsEl.appendChild(lDiv);bars.appendChild(bDiv);+  // Last, because everything it marks has to exist first: both row divs are assigned on+  // the line above and the bar went into barEls a few lines before that. Here rather+  // than in buildRows, because insertRow builds a NEW row through this function alone,+  // so a row created already cancelled has to come out of the panel looking it.+  syncCancelMark(r); } let rowsBuilt=false; function buildRows(){@@ -4117,6 +4164,123 @@ function depPts(x1,y1,x2,y2,ctx,self){ function depPath(x1,y1,x2,y2,ctx,self){   return roundedPath(depPts(x1,y1,x2,y2,ctx||depCtx(),self),DEP_R); }+// ── Arrow colour ───────────────────────────────────────────────────────────+//+// An arrow is painted in the SOURCE row's phase colour, so where a chain comes FROM+// is readable without tracing it back. The tail and not the head, deliberately: an+// arrow here answers "what is this waiting on", and the answer is the thing at the+// tail. Every arrow used to be the same teal, which said only "there is a dependency"+// and made a plan with forty of them a thicket.+//+// A phase colour was chosen to be a 20px-tall FILL. A 1.5px stroke is a different job+// with about a twentieth of the area, so a colour that reads perfectly as a bar can+// disappear as a line. The three ramp colours are safe by construction, because they+// are picked against the page in each theme. An EXPLICIT lane colour is not: it is+// theme-invariant by design (0.30.0), so somebody who paints a lane navy has painted+// an invisible arrow on the dark theme without meaning to, and somebody who paints one+// pale yellow has done the same on paper.+//+// So the stroke is DERIVED and never merely used. The formula, stated once and+// implemented immediately below:+//+//   1. resolve the phase colour to r,g,b, through the same cssRgbTriple the hover wash+//      uses, so a var(--phN) and an explicit hex both come out as three numbers+//   2. take the WCAG relative luminance of it and of the page, --bg+//   3. if their contrast ratio is at or above FLOOR the colour is used unchanged+//   4. otherwise hold HUE and SATURATION and walk LIGHTNESS away from the page in 2%+//      steps, re-measuring each step, until the ratio clears the floor or lightness+//      runs out of room+//+// FLOOR is 3.0, the WCAG 2.1 minimum for a graphical object, which is what a line is.+// Text would be 4.5 and would push half the ramp to pastel for no gain; the arrow only+// has to be findable, and the name beside it is what carries the meaning.+//+// Holding hue is what makes step 4 a repair rather than a substitution. A navy lane+// gets a lighter blue arrow that is still recognisably that lane, instead of falling+// back to the one teal that used to paint every arrow and told you nothing. Walking+// AWAY from the page means the direction is decided by the theme rather than fixed:+// the same navy is lifted on the dark theme and left alone on paper.+const DEP_STROKE_FLOOR=3.0;+const depStrokeMemo=new Map();+function srgbLin(c){ c/=255; return c<=0.03928?c/12.92:Math.pow((c+0.055)/1.055,2.4); }+function relLum(t){ return 0.2126*srgbLin(t[0])+0.7152*srgbLin(t[1])+0.0722*srgbLin(t[2]); }+function contrastOf(a,b){ const hi=Math.max(a,b),lo=Math.min(a,b); return (hi+0.05)/(lo+0.05); }+function rgbToHsl(r,g,b){+  r/=255;g/=255;b/=255;+  const mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn,l=(mx+mn)/2;+  if(!d)return [0,0,l];+  const s=l>0.5?d/(2-mx-mn):d/(mx+mn);+  let h;+  if(mx===r)h=((g-b)/d+(g<b?6:0));+  else if(mx===g)h=(b-r)/d+2;+  else h=(r-g)/d+4;+  return [h/6,s,l];+}+function hslToRgb(h,s,l){+  if(!s){const v=Math.round(l*255);return [v,v,v];}+  const q=l<0.5?l*(1+s):l+s-l*s, p=2*l-q;+  const f=t=>{ t=(t+1)%1;+    if(t<1/6)return p+(q-p)*6*t;+    if(t<1/2)return q;+    if(t<2/3)return p+(q-p)*(2/3-t)*6;+    return p; };+  return [Math.round(f(h+1/3)*255),Math.round(f(h)*255),Math.round(f(h-1/3)*255)];+}+function tripleOf(css){ const s=cssRgbTriple(css); return s?s.split(' ').map(Number):null; }+function depStroke(css){+  const key=(document.documentElement.getAttribute('data-theme')||'')+'|'+String(css||'');+  if(depStrokeMemo.has(key))return depStrokeMemo.get(key);+  let out=null;+  const t=tripleOf(css), bg=tripleOf('var(--bg)');+  if(t&&bg){+    const lb=relLum(bg);+    if(contrastOf(relLum(t),lb)>=DEP_STROKE_FLOOR)out='rgb('+t.join(',')+')';+    else{+      // Away from the page: on a dark page lightness goes up, on a light one it goes+      // down. A step of 2% is fine enough that the lift is never more than it has to+      // be and coarse enough that the walk is at most fifty iterations, once, memoised.+      const [h,s,l0]=rgbToHsl(t[0],t[1],t[2]);+      const up=lb<0.5, step=up?0.02:-0.02;+      let best=t;+      for(let l=l0+step; up?l<=1.0001:l>=-0.0001; l+=step){+        const c=hslToRgb(h,s,Math.max(0,Math.min(1,l)));+        best=c;+        if(contrastOf(relLum(c),lb)>=DEP_STROKE_FLOOR)break;+      }+      out='rgb('+best.join(',')+')';+    }+  }+  depStrokeMemo.set(key,out);+  return out;+}+// The colour an arrow out of this row should be. A row with no phase (which nothing+// arrow-bearing has today) falls back to nothing, and nothing means the CSS default,+// which is still the accent.+function depStrokeFor(r){ return r&&r.phase!==undefined?depStroke(phaseColor(r.phase)):null; }+function paintDepEdge(el,stroke){+  // A custom property and NOT a style.stroke. An inline stroke would beat every class+  // on the element, and the three states that override the base colour are all+  // classes: the picked arrow's amber, the armed-delete red and the critical path's+  // white. Setting the property leaves the base rule to read it and leaves each of+  // those rules to win over the base rule exactly as it did before, so the precedence+  // this round inherits from 0.39.0, 0.42.0 and 0.49.0 is untouched.+  if(stroke)el.style.setProperty('--dep-stroke',stroke);+  else el.style.removeProperty('--dep-stroke');+}+// Repaint the arrows ALREADY on screen from the phases as they stand now. drawDeps+// paints on the way past, but recolouring a lane and moving a row into another lane+// both change an arrow's colour without moving a single pixel of geometry, so there+// would be nothing to trigger a redraw and the arrow would keep the old lane's colour+// until the next resize. This is a walk of the paths already in the layer setting one+// custom property each, which is cheap enough to hang off every one of those paths.+function recolorDepArrows(){+  if(!depSvg)return;+  const byId={};+  items.forEach(r=>{ if(r.id)byId[r.id]=r; });+  depSvg.querySelectorAll('[data-from]').forEach(el=>{+    paintDepEdge(el,depStrokeFor(byId[el.getAttribute('data-from')]));+  });+} function drawDeps(){   depSvg.innerHTML='';let maxY=0;   // One measurement of the bars for the whole layer, not one per arrow.@@ -4137,10 +4301,13 @@ function drawDeps(){       // rather than extra nodes: the layer is redrawn wholesale on every geometry       // change, so a pick has to be re-applied from state afterwards, and one path       // plus one head per edge is what every other reader of this layer counts on.+      // The SOURCE row's phase, derived once per edge and put on both halves of it, so+      // the line and the head it ends in are one colour and cannot drift apart.+      const stroke=depStrokeFor(sItem);       const p=document.createElementNS('http://www.w3.org/2000/svg','path');p.setAttribute('d',depPath(x1,y1,x2,y2,ctx,{from:depId,to:r.id}));-      p.setAttribute('data-from',depId);p.setAttribute('data-to',r.id);depSvg.appendChild(p);+      p.setAttribute('data-from',depId);p.setAttribute('data-to',r.id);paintDepEdge(p,stroke);depSvg.appendChild(p);       const a=document.createElementNS('http://www.w3.org/2000/svg','polygon');a.setAttribute('points',`${x2},${y2} ${x2-6},${y2-3} ${x2-6},${y2+3}`);-      a.setAttribute('data-from',depId);a.setAttribute('data-to',r.id);depSvg.appendChild(a);+      a.setAttribute('data-from',depId);a.setAttribute('data-to',r.id);paintDepEdge(a,stroke);depSvg.appendChild(a);       maxY=Math.max(maxY,y1,y2);});});   depSvg.setAttribute('height',Math.max(maxY+40,bars.scrollHeight));   // The redraw threw away the classes, so the pick, the armed state and the critical@@ -4791,6 +4958,10 @@ function recolorPhase(i,color){     if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(i);     if(r._msLine)r._msLine.style.color=phaseColor(i);   });+  // Every other recolour path ends in refreshChartGeometry, which redraws the arrow+  // layer and repaints it on the way past. Repainting a LANE moves no geometry at all,+  // so this is the one that has to say so itself.+  recolorDepArrows();   refreshSections();   logPhaseEvent({action:'phase.color',phase:p.name,from:{color:from},to:{color:color||'(theme ramp)'}});   scheduleSave('section-color');@@ -5128,6 +5299,7 @@ function snapshotMs(r){ // dashed rule's tag all restate the milestone, so they move together. function refreshMsChrome(r){   if(!r)return;+  syncCancelMark(r);   if(r._nameSpan)r._nameSpan.innerHTML=nameInner(r);   else if(r._lDiv)r._lDiv.textContent=r.name;   if(r._msHit){r._msHit.dataset.title=r.name;r._msHit.dataset.body=r.description||'';r._msHit.dataset.prov=provLabel(r);}@@ -5143,7 +5315,22 @@ function updateBar(id){const r=items.find(d=>d.id===id),be=barEls[id];if(!r||!be // The shape goes with the content: every opener declares which one it wants, so closing // has to leave the element without any of them rather than let the last panel's aspect // or its metrics ride along into the next one.-function closeEditor(){edSubject=null;editorEl.classList.remove('show','square','compact');editorEl.innerHTML='';}+// ONE keydown listener on the panel at a time. Both panels used to add their own on+// every open, to an element that OUTLIVES them: .editor is permanent and only its+// innerHTML is replaced. So the listeners piled up, and after three trips through the+// edit panel an Enter pressed in the CREATE panel ran three stale edit-panel handlers,+// each reaching for an Apply button that was no longer on the page and each throwing.+// Swapping rather than adding means exactly one is live and it always belongs to the+// panel that is actually mounted. wireEditorTab's listener is not one of these: it is+// wired once at start-up, reads only the focused element, and outlives every panel on+// purpose.+let edPanelKeys=null;+function setEditorKeys(fn){+  if(edPanelKeys)editorEl.removeEventListener('keydown',edPanelKeys);+  edPanelKeys=fn||null;+  if(edPanelKeys)editorEl.addEventListener('keydown',edPanelKeys);+}+function closeEditor(){edSubject=null;setEditorKeys(null);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) ──@@ -6543,6 +6730,12 @@ function wireThemeButton(){ $('btnTheme').addEventListener('click',()=>{   prefs.theme = prefs.theme==='dark' ? 'light' : 'dark';   applyTheme();savePrefs();syncThemeBtn();+  // An arrow's colour is DERIVED from the phase and the page, and both of those just+  // changed: the ramp is a different set of hexes in the other theme, and the page the+  // stroke is measured against is the opposite end of the scale. The value written on+  // the element is a resolved rgb(), so nothing recomputes it on its own. The memo is+  // keyed by theme, so this is a recompute and not a stale read.+  recolorDepArrows(); }); syncThemeBtn(); }@@ -6592,22 +6785,48 @@ function openItemModal(r,carriedName){   edSubject=r;   const ms=r.type==='milestone';   const p=provOf(r)||{label:'',url:''};+  // FIELD FOR FIELD THE NEW-ITEM PANEL, in the same order, in the same rows, with the+  // same labels, hints and placeholders. They were two layouts doing one job: this one+  // led with the name and put phase on a row of its own, the create panel led with type+  // and phase and put the name under them, and a person who had just created three rows+  // had to re-find every field the moment they went to edit one. The create panel is the+  // one that stayed, because it is the one people meet first.+  //+  // Three deliberate differences remain, and they are the three places where editing an+  // existing row is genuinely not the same job as making a new one:+  //+  //   TYPE is present but DISABLED. It has to be present or every row below it would sit+  //   one line higher than it does in the other panel and the two would not match. It has+  //   to be disabled because nothing in this app converts a task into a milestone: a task+  //   has a start and an end and a bar, a milestone has a single date and a diamond, and+  //   silently dropping one of a task's two dates is not a conversion. Disabled rather+  //   than hidden also means it still SAYS what the row is, which is worth a line.+  //+  //   STATUS keeps its "none" option. The create panel has no need of it (a new row opens+  //   on not-started), but an existing row can have no status at all and there has to be+  //   a way back to that, or the first person to set a status by accident could never+  //   clear it.+  //+  //   THE BUTTONS are Apply / Cancel / Delete rather than Create / Cancel, with Delete+  //   holding the margin-left:auto that has kept the destructive button away from the+  //   other two since 0.37.0.   editorEl.innerHTML=`<div class="drag-bar"></div>     <div class="batch-title">Edit ${ms?'milestone':'task'}</div>-    <div class="de-note-row"><label>name <span class="de-hint">required</span></label><input type="text" id="imName"></div>-    <div class="de-row" style="margin-top:8px">+    <div class="de-row">+      <div><label>type</label><select id="imType" disabled title="A task has a start and an end, a milestone has one date. Changing one into the other is not an edit, so make the row you want and delete this one."><option value="task">Task</option><option value="milestone">Milestone</option></select></div>       <div style="flex:1"><label>phase</label><select id="imPhase">${phases.map((ph,i)=>`<option value="${i}">${esc(ph.name)}</option>`).join('')}</select></div>     </div>-    <div class="de-row">+    <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>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">-      ${ms?'':'<div><label>start</label><input type="date" id="imStart"></div>'}-      <div><label>${ms?'date':'deadline'}</label><input type="date" id="imEnd"></div>+      <div id="imStartWrap"><label>start</label><input type="date" id="imStart"></div>+      <div><label id="imEndLabel">deadline</label><input type="date" id="imEnd"></div>     </div>-    <div class="de-note-row"><label>notes</label><input type="text" id="imDesc"></div>-    <div class="de-note-row"><label>provenance</label><input type="text" id="imProv" placeholder="where this came from"></div>+    <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-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>@@ -6615,7 +6834,14 @@ function openItemModal(r,carriedName){   // values assigned, never interpolated, so nothing typed here can become markup   $('imName').value=carriedName!==undefined?carriedName:(r.name||'');   $('imPhase').value=String(r.phase);-  if(!ms&&$('imStart'))$('imStart').value=r.start||'';+  // The start field is HIDDEN for a milestone and not removed, which is exactly what+  // the create panel does when its type select is set to Milestone. Removing it would+  // put the two panels one element apart in a walk of their fields, and matching them+  // is the whole point of this pass.+  $('imType').value=ms?'milestone':'task';+  $('imStartWrap').style.display=ms?'none':'';+  $('imEndLabel').textContent=ms?'date':'deadline';+  $('imStart').value=ms?'':(r.start||'');   $('imEnd').value=(ms?r.date:r.end)||'';   $('imOwner').value=r.owner||'';   $('imStatus').value=statusOf(r);@@ -6624,7 +6850,7 @@ function openItemModal(r,carriedName){   const rect=(r._lDiv||document.body).getBoundingClientRect();   showEditor(rect.right+12,rect.top,false);   $('imName').focus();$('imName').select();-  editorEl.addEventListener('keydown',e=>{e.stopPropagation();+  setEditorKeys(e=>{e.stopPropagation();     if(e.key==='Enter'&&e.target.tagName!=='BUTTON'){e.preventDefault();$('imApply').click();}     else if(e.key==='Escape'){e.preventDefault();closeEditor();}});   $('imCancel').addEventListener('click',closeEditor);@@ -6638,7 +6864,7 @@ function openItemModal(r,carriedName){     const end=$('imEnd').value;     if(ms){ if(end)r.date=end; }     else{-      const st=$('imStart')?$('imStart').value:r.start;+      const st=$('imStart').value;       if(st)r.start=st;       if(end)r.end=end;       if(r.start&&r.end&&r.start>r.end)r.start=r.end;@@ -6741,7 +6967,7 @@ function openCreateEditor(){   $('ncType').addEventListener('change',syncType); syncType();   $('ncName').focus();   $('ncCancel').addEventListener('click',closeEditor);-  editorEl.addEventListener('keydown',e=>{e.stopPropagation();+  setEditorKeys(e=>{e.stopPropagation();     if(e.key==='Enter'&&e.target.tagName!=='BUTTON'){e.preventDefault();$('ncCreate').click();}     else if(e.key==='Escape'){e.preventDefault();closeEditor();}});   // Async, because a new row cannot exist until the server has given it a uid.@@ -7305,11 +7531,27 @@ function rowItem(key){ // Re-render the chart's own label row, so a status changed in the table is // already true on the chart when you toggle back. function refreshLabelRow(r){-  if(!r||!r._lDiv||!r.id)return;+  if(!r||!r._lDiv)return;+  // The cancel mark first, and outside the id guard: a milestone reaches this+  // function from the bulk restate at load and has no id to pass the guard with,+  // and its row goes quiet for the same reason a task's does.+  syncCancelMark(r);+  if(!r.id)return;   // into the name span only: rewriting the row would take the edit button with it   if(r._nameSpan)r._nameSpan.innerHTML=nameInner(r);   else r._lDiv.innerHTML=nameInner(r); }+// Cancelled work stays on the plan and stays readable; it just stops asking for+// attention. The mark is a class carried on the row's own elements rather than a+// question every reader re-asks, so the chart, the label column and the table all go+// quiet in the same instant and there is one place to look when they do not.+function syncCancelMark(r){+  if(!r)return;+  const on=isCancelled(r);+  [r._lDiv,r._bDiv,r._msEl,r._msLine].forEach(el=>{ if(el)el.classList.toggle('cancelled',on); });+  const be=r.id&&barEls[r.id];+  if(be&&be.bar)be.bar.classList.toggle('cancelled',on);+}  function distinctOwners(){   const s=new Set();@@ -7441,7 +7683,8 @@ function renderTable(){     const dot=r.phase!==undefined?`<span class="phase-dot" style="background:${esc(phaseColor(r.phase))}"></span>`:'';     const dates=ms?(dl?fmt(dl):''):((sd&&dl)?fmt(sd)+' → '+fmt(dl):'');     const ed=editable?' editable':'';-    return `<tr data-key="${esc(rowKey(r))}"${ms?' class="is-ms"':''}>`+    const trCls=[ms?'is-ms':'',isCancelled(r)?'cancelled':''].filter(Boolean).join(' ');+    return `<tr data-key="${esc(rowKey(r))}"${trCls?` class="${trCls}"`:''}>`       +`<td class="c-name${ed}" data-field="name"><span class="trunc" data-title="${esc(r.name)}" data-dates="${esc(dates)}" data-body="${esc(r.description||'')}">`         +`${ms?'<span class="ms-mark">◆</span>':''}${esc(r.name)}</span></td>`       +`<td class="c-phase${ed}" data-field="phase">${dot}${esc(phaseNameOf(r))}</td>`@@ -7929,10 +8172,69 @@ let tblEditing=null; // instead, so landing on a dropdown still shows the choices and leaving it still // costs exactly one Tab. let cellList=null;+// ── Keeping a popup list open while it is being SCROLLED ───────────────────+//+// Every list of our own is a fixed-position box anchored to something the page can+// scroll out from under it, so all of them close when the page scrolls: an option list+// left hanging over a cell that is no longer there is worse than one that went away.+// That is why the listener is `scroll` in the CAPTURE phase, which is the only way to+// hear a scroll of an inner pane at all, since scroll does not bubble.+//+// Those two facts collided. A capture listener on window hears a scroll of EVERY+// element on the page, and the list itself is one of them: it is 232px tall with+// overflow-y auto, so reaching the tenth name in it means scrolling it, and scrolling+// it dismissed it before the name could be clicked. That is Drew's report on 0.51.0,+// and it applied to the owner pill, the status pill and every select cell in the table+// alike, because all three go through this one pair of listeners.+//+// The guard is the event's TARGET. A scroll that started inside a list of ours is that+// list scrolling itself, which is somebody reading it and never a reason to take it+// away. A scroll anywhere else is the page moving under the anchor, which still is.+function inOwnPopup(ev){+  const t=ev&&ev.target;+  return !!(t&&t.nodeType===1&&t.closest&&t.closest('.cell-list'));+}+function popupScrollGuard(close){ return ev=>{ if(!inOwnPopup(ev))close(); }; }+// The OTHER half of the same report. Both lists cancel mousedown, for two good reasons+// (never steal focus from the offscreen select, never let the native select popup open+// on top of ours), but they cancelled it everywhere on the element including its own+// scrollbar, and preventDefault on a scrollbar mousedown cancels the browser's native+// drag. So the bar could be grabbed and would not move: the list stayed open, which+// looked like nothing happening at all, and the wheel that followed then dismissed it.+//+// A mousedown on an element's own scrollbar arrives with that element as the TARGET,+// which is the first half of telling it apart from a press on an option: over a child+// the offsets are the child's own and say nothing about the scrollbar.+//+// The second half depends on which kind of scrollbar the platform draws, and both kinds+// are in the field. A CLASSIC scrollbar (Chrome on Windows and on most Linux desktops,+// which is where Drew is) takes layout width, so it sits in the gutter between the+// client box and the border and `offsetX >= clientWidth` names it exactly. An OVERLAY+// scrollbar (macOS, and headless Chrome) takes no layout width at all, so there is no+// gutter to measure and the only signal left is the band at the edge it is painted+// over. Both are only live when the element has something to scroll, which is what+// stops a press on the right-hand padding of a short list from being read as a grab.+const OVERLAY_SB=15;         // Chrome's overlay thickness, plus a pixel of slop+function onOwnScrollbar(el,ev){+  if(!el||ev.target!==el)return false;+  const bw=Math.max(0,el.offsetWidth-el.clientWidth-2*el.clientLeft);   // 0 when overlay+  const bh=Math.max(0,el.offsetHeight-el.clientHeight-2*el.clientTop);+  if(el.scrollHeight>el.clientHeight){+    const edge=bw>0?el.clientWidth:el.clientWidth-OVERLAY_SB;+    if(ev.offsetX>=edge)return true;+  }+  if(el.scrollWidth>el.clientWidth){+    const edge=bh>0?el.clientHeight:el.clientHeight-OVERLAY_SB;+    if(ev.offsetY>=edge)return true;+  }+  return false;+}+let cellListScroll=null; function closeCellList(){   if(!cellList)return;   cellList.el.remove();-  window.removeEventListener('scroll',closeCellList,true);+  if(cellListScroll)window.removeEventListener('scroll',cellListScroll,true);+  cellListScroll=null;   window.removeEventListener('resize',closeCellList);   cellList=null; }@@ -7964,13 +8266,17 @@ function openStatusPill(r,pillEl){   sel.style.cssText='position:fixed;left:-9999px;top:0;opacity:0';   document.body.appendChild(sel);   openCellList(sel,pillEl);+  // escKey and NOT esc: `esc` is the app's HTML-escape helper, and a local of that name+  // shadows it for the WHOLE function, which put the esc(cur) two lines above this into+  // the temporal dead zone. That throw only fires for a row carrying a status word none+  // of ours matches, which is why nobody had met it; the rename retires it.   const cleanup=()=>{ closeCellList(); if(sel.parentNode)sel.remove();-    document.removeEventListener('mousedown',outside,true); document.removeEventListener('keydown',esc,true); };+    document.removeEventListener('mousedown',outside,true); document.removeEventListener('keydown',escKey,true); };   const outside=ev=>{ if(!ev.target.closest('.cell-list'))cleanup(); };-  const esc=ev=>{ if(ev.key==='Escape'){ ev.stopPropagation(); cleanup(); } };+  const escKey=ev=>{ if(ev.key==='Escape'){ ev.stopPropagation(); cleanup(); } };   sel.addEventListener('change',()=>{ const v=sel.value; cleanup(); commitCell(r,'status',v); renderTable(); });   // 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);+  setTimeout(()=>{ document.addEventListener('mousedown',outside,true); document.addEventListener('keydown',escKey,true); },0); }  // The owner pill in the label column, editable in place. Same deal as the status pill@@ -7980,7 +8286,7 @@ function openStatusPill(r,pillEl){ // 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+// of five; the people on a project are an open set, so a dropdown alone would be a // dead end the first time someone joins. Anyone already on the plan is one click, a // new name is one line of typing, and the moment it commits distinctOwners picks it up // so it is in this list for every other row, and in the table's filter and datalist.@@ -8005,11 +8311,15 @@ function openOwnerPill(r,pillEl){   el.style.top=(below>el.offsetHeight+8?rect.bottom+2:Math.max(4,rect.top-el.offsetHeight-2))+'px';    let done=false;+  // Named, so the same function object that was added is the one removed. It used to+  // be `cleanup` itself, which was symmetrical but heard the list scrolling itself.+  const onScroll=popupScrollGuard(()=>cleanup());   const cleanup=()=>{     if(done)return; done=true;     el.remove();     document.removeEventListener('mousedown',outside,true);-    window.removeEventListener('scroll',cleanup,true);+    document.removeEventListener('keydown',escKey,true);+    window.removeEventListener('scroll',onScroll,true);     window.removeEventListener('resize',cleanup);   };   // One commit path for both halves of the control, the list and the field, so a@@ -8023,7 +8333,18 @@ function openOwnerPill(r,pillEl){     renderTable();   };   const outside=ev=>{ if(!ev.target.closest('.owner-list'))cleanup(); };-  el.addEventListener('mousedown',ev=>{ if(!ev.target.closest('.cl-new-inp'))ev.preventDefault(); });+  // Escape closes it, the same as the status list beside it and the same as every panel+  // in the app. This list never had it: it carried an Escape on its type-in FIELD only,+  // so the key worked if you had clicked into the field and did nothing at all if you+  // had just opened the list and changed your mind, which is the common case. On the+  // document and in the capture phase, which is where the status list puts its own.+  const escKey=ev=>{ if(ev.key==='Escape'){ ev.stopPropagation(); ev.preventDefault(); cleanup(); } };+  // The type-in field needs its mousedown (it has to be focusable and clickable into),+  // and so does the list's own scrollbar, or the bar can be grabbed and will not move.+  el.addEventListener('mousedown',ev=>{+    if(ev.target.closest('.cl-new-inp')||onOwnScrollbar(el,ev))return;+    ev.preventDefault();+  });   el.addEventListener('click',ev=>{     const o=ev.target.closest('.cl-opt'); if(!o)return;     ev.stopPropagation();@@ -8040,7 +8361,8 @@ function openOwnerPill(r,pillEl){   inp.addEventListener('blur',()=>{ const v=inp.value.trim(); if(!done&&v)commit(v); });   setTimeout(()=>{     document.addEventListener('mousedown',outside,true);-    window.addEventListener('scroll',cleanup,true);+    document.addEventListener('keydown',escKey,true);+    window.addEventListener('scroll',onScroll,true);     window.addEventListener('resize',cleanup);   },0); }@@ -8055,14 +8377,17 @@ function openCellList(sel,td){   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';-  el.addEventListener('mousedown',e=>e.preventDefault());   // never steal focus from the select+  // never steal focus from the select, EXCEPT on the list's own scrollbar, where+  // cancelling the mousedown would cancel the native drag along with it+  el.addEventListener('mousedown',e=>{ if(!onOwnScrollbar(el,e))e.preventDefault(); });   el.addEventListener('click',e=>{     const o=e.target.closest('.cl-opt'); if(!o)return;     sel.value=o.dataset.value;     sel.dispatchEvent(new Event('change',{bubbles:true}));   });   cellList={el,sel};-  window.addEventListener('scroll',closeCellList,true);+  cellListScroll=popupScrollGuard(closeCellList);+  window.addEventListener('scroll',cellListScroll,true);   window.addEventListener('resize',closeCellList);   syncCellList(); }@@ -8487,7 +8812,15 @@ function analyticsModel(){   const rows=items.filter(r=>!isGroup(r));   const tasks=rows.filter(r=>!isMs(r)&&r.start&&r.end);   const ms=rows.filter(r=>isMs(r)&&r.date);-  const totalHours=tasks.reduce((a,r)=>a+taskHours(r),0);+  // effortHoursOf, not taskHours: cancelled work is not effort anybody owes, so it is+  // out of every hours figure on this lens. It is gated at the three ACCUMULATORS+  // (here, the owner bucket and the section reduce) rather than inside taskHours,+  // because the row's own hours are still its hours: the bar, the tooltip, the undo+  // snapshot and the export all go on saying what they always said, and it is only the+  // sums that stop including it. This total is also the divisor for the per-person+  // share bars, so cancelling work makes everyone else's percentage go up, which is+  // the true reading: their share of what is left to do really did grow.+  const totalHours=tasks.reduce((a,r)=>a+effortHoursOf(r),0);   const dates=[];   tasks.forEach(r=>{dates.push(r.start,r.end);});   ms.forEach(r=>dates.push(r.date));@@ -8508,7 +8841,10 @@ function analyticsModel(){     b.status[st]=(b.status[st]||0)+1;     if(isMs(r)){ if(r.date)b.ms++; return; }     if(!r.start||!r.end)return;-    b.tasks++; b.hours+=taskHours(r);+    // The task COUNT still counts it and the status column still counts it: a+    // cancelled row is a real decision somebody made and the card has to show it.+    // Only the hours stop.+    b.tasks++; b.hours+=effortHoursOf(r);   });   const people=[...byOwner.values()]     .filter(b=>b.tasks||b.ms)@@ -8526,14 +8862,19 @@ function analyticsModel(){     mt.forEach(r=>{ds.push(r.start,r.end);});     mine.filter(r=>isMs(r)&&r.date).forEach(r=>ds.push(r.date));     return {i,name:ph.name,color:phaseColor(i),items:mine.length,-            hours:mt.reduce((a,r)=>a+taskHours(r),0),+            hours:mt.reduce((a,r)=>a+effortHoursOf(r),0),             from:ds.length?ds.reduce((m,d)=>dayNum(d)<dayNum(m)?d:m):'',             to:ds.length?ds.reduce((m,d)=>dayNum(d)>dayNum(m)?d:m):''};   });   const today=anToday();+  // Overdue is "this was due and it did not happen", so it takes a SET of statuses+  // that settle a row rather than the single compare it used to be: complete means it+  // happened, cancelled means it was called off, and neither is late. Everything else,+  // including a blank status, is still owed.+  const SETTLED=new Set(['complete','cancelled']);   const overdue=rows.filter(r=>{     const dl=deadlineOf(r);-    return dl&&statusOf(r)!=='complete'&&dayNum(dl)<dayNum(today);+    return dl&&!SETTLED.has(statusOf(r))&&dayNum(dl)<dayNum(today);   }).sort((a,b)=>dayNum(deadlineOf(a))-dayNum(deadlineOf(b)));   const g=depGraph();   const cyc=topoOrder(g).stuck;@@ -8586,7 +8927,7 @@ function renderAnalytics(){    // 2. per person: the load and the state of it, on ONE row each   //-  // These were two cards, one for hours and one for the four status counts, over the+  // These were two cards, one for hours and one for the status counts, over the   // same list of owners in the same order. Reading them meant finding the same name   // twice and holding the first half in your head, which is exactly the comparison a   // table exists to do for you. Hours come first because that is what the card is@@ -8602,7 +8943,7 @@ function renderAnalytics(){       +STATUS_ORDER.map(k=>'<td class="n'+((b.status[k]||0)?'':' muted')+'">'+(b.status[k]||0)+'</td>').join('')       +'</tr>').join('');   out.push(card('wide','Per person',-    'Every owner in the plan, unassigned included, sorted by hours. Milestones are dates rather than work, so they carry no hours and are counted separately as +nM, but they do carry a status and are counted in the four columns on the right.',+    'Every owner in the plan, unassigned included, sorted by hours. Milestones are dates rather than work, so they carry no hours and are counted separately as +nM, but they do carry a status and are counted in the '+STATUS_ORDER.length+' status columns on the right. Cancelled work still counts as an item and still shows in its column; its hours do not, so a cancelled task leaves the count alone and takes its hours out of the share.',     m.people.length?('<table class="an-table people"><thead><tr><th>Owner</th><th>Share</th><th class="n">Hours</th><th class="n">%</th><th class="n">Items</th>'       +STATUS_ORDER.map(k=>'<th class="n">'+esc(statusTextFor(k))+'</th>').join('')       +'</tr></thead><tbody>'
package.json+2−2
@@ -2,8 +2,8 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.51.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, and one more lights up the critical path, meaning the longest run of work inside every section at once. 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. 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.",+  "version": "0.52.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, and one more lights up the critical path, meaning the longest run of work inside every section at once. 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. Five statuses, including cancelled, which keeps 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": {     "adom-gantt": "server.js"
page.json+2−2
@@ -2,8 +2,8 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.51.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, and one more lights up the critical path, meaning the longest run of work inside every section at once. 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. 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.",+  "version": "0.52.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, and one more lights up the critical path, meaning the longest run of work inside every section at once. 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. Five statuses, including cancelled, which keeps 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": {     "adom-gantt": "server.js"
server.js+43−12
@@ -782,14 +782,21 @@ function changelogMarkdown(log, since, rev) {   return lines.join('\n') + '\n'; } -// The status vocabulary is four states. Old values are aliased on READ so stored+// The status vocabulary is five states. Old values are aliased on READ so stored // data keeps working untouched; only a fresh write uses the new words. Kept in-// step with statusKey() in gantt.html.-const STATUS_ORDER = ['not-started', 'in-progress', 'blocked', 'complete'];+// step with statusKey() in gantt.html, and that lockstep is the whole point of the+// duplication: the server normalises on load, on import and on save, so a word this+// list does not know survives as itself but never becomes one of ours. A fifth state+// added on one side only would be half-supported in silence, which is worse than not+// having it, so the two lists change together or not at all.+const STATUS_ORDER = ['not-started', 'in-progress', 'blocked', 'complete', 'cancelled']; const STATUS_ALIAS = {   'live': 'in-progress', 'in progress': 'in-progress', 'in_progress': 'in-progress',   'planned': 'not-started', 'not started': 'not-started', 'notstarted': 'not-started',   'done': 'complete', 'completed': 'complete',+  // American spelling, and the two phrasings a tracker exports it as.+  'canceled': 'cancelled', 'cancel': 'cancelled', 'abandoned': 'cancelled',+  "won't do": 'cancelled', 'wont do': 'cancelled', 'wontdo': 'cancelled', 'dropped': 'cancelled', }; function statusKey(v) {   const t = String(v == null ? '' : v).trim().toLowerCase();@@ -1596,9 +1603,13 @@ function importMSProjectXML(body) {       return m ? m[1] : '';     }; +    // Current vocabulary, for the same reason the JIRA map uses it: an import that+    // writes the pre-0.31 words puts a value into the plan file that statusKey has to+    // translate back on every read. MS Project has no percentage that means cancelled,+    // so the fifth state is never produced here.     let status = 'not-started';-    if (pctComplete >= 100) status = 'live';-    else if (pctComplete > 0) status = 'planned';+    if (pctComplete >= 100) status = 'complete';+    else if (pctComplete > 0) status = 'in-progress';      tasks.push({       type: 'task',@@ -1651,15 +1662,29 @@ function importJiraCSV(body) {   const rows = parseCSV(body);   if (rows.length === 0) throw new Error('JIRA CSV has no data rows'); +  // JIRA's own words on the left, ours on the right. The right-hand side is written in+  // the CURRENT vocabulary and no longer in the pre-0.31 one: statusKey would alias the+  // old words on the next read anyway, but an import that writes them puts a word into+  // the plan file that the app immediately has to translate back, and a reader opening+  // that file sees a vocabulary the app does not offer.   const statusMap = {-    'done': 'live',-    'closed': 'live',-    'resolved': 'live',-    'in progress': 'planned',-    'in review': 'planned',+    'done': 'complete',+    'closed': 'complete',+    'resolved': 'complete',+    'in progress': 'in-progress',+    'in review': 'in-progress',     'to do': 'not-started',     'open': 'not-started',     'backlog': 'not-started',+    // The fifth state. JIRA spells it several ways depending on the workflow, and a+    // cancelled issue imported as "not started" would come in as work still owed.+    'cancelled': 'cancelled',+    'canceled': 'cancelled',+    "won't do": 'cancelled',+    'wont do': 'cancelled',+    'abandoned': 'cancelled',+    'rejected': 'cancelled',+    'duplicate': 'cancelled',   };    const items = [];@@ -1895,8 +1920,14 @@ function generateSVG(dataArg) {       const color = svgPhaseColor(phases[r.phase], r.phase);        // Status tag-      const statusColors = { live: '#3fb950', planned: '#64ABFF', 'not-started': '#484f58' };-      const statusLabels = { live: 'live', planned: 'planned', 'not-started': 'not started' };+      // Keyed on the CURRENT vocabulary, with the pre-0.31 words kept alongside so an+      // SVG rendered straight from an old data file still colours its tags.+      const statusColors = { 'not-started': '#484f58', 'in-progress': '#00b8b1', blocked: '#d29922',+                             complete: '#3fb950', cancelled: '#6e7681',+                             live: '#3fb950', planned: '#64ABFF' };+      const statusLabels = { 'not-started': 'not started', 'in-progress': 'in progress', blocked: 'blocked',+                             complete: 'complete', cancelled: 'cancelled',+                             live: 'live', planned: 'planned' };       const stag = r.status ? ` [${statusLabels[r.status] || r.status}]` : '';        svg += `\n<text class="bar-label" x="${PAD}" y="${y + h / 2 + 4}">${esc(r.name)}</text>`;