Download

adom-gantt build notes

Decisions taken while building, and the places where the plan and the code disagreed. Newest first.

Build notes, 2026-08-10 (0.19.0, arrow keys walk the table)

"Up down arrow keys should move which row is selected. Hitting Enter on a row should make the highlighted task's name editable. No value from up/down keys moving the vertical scroll bar."

The focused row from 0.17.0 was already the selection, so this is arrows driving a model that existed rather than a second one beside it. Up and Down walk the rows the table is SHOWING, which is what makes a sort or a filter change the meaning of "the next row" without the walk knowing anything about either. Measured under plan order, under an owner sort, and with an owner filter down to 15 rows.

preventDefault is the feature, not an implementation detail. The request was that the arrows stop nudging the scrollbar, so they are consumed whenever the table can act on them, and the pane only moves when the row you chose is off the edge. scrollRowIntoView does it by hand rather than through scrollIntoView({block: 'nearest'}) for two reasons: the header is sticky, so the usable top of the pane is the header's bottom and not the pane's, and scrollIntoView walks every scrollable ancestor while only this one should move. Measured: five presses with the rows in view leave scrollTop at exactly 0, and walking seven rows past the fold scrolls 215px against a 29px row, which is the overshoot and nothing more, with the focused row landing inside the pane and below the header.

The first press lands on the first visible row and moves nothing. A walk has to start somewhere and starting from nowhere is the one case a user cannot predict. If a row was touched earlier it is already the focused row and the walk carries on from it, so the "last-touched" behaviour is there for free without a second rule.

Enter, twice, is the loop. On a focused row Enter opens the NAME editor, which is the field a person almost always means and the one the chart's label click already opens, ready to type with the text selected. Inside a cell editor Enter commits and steps down one row, the spreadsheet convention, so a column of values is Enter, type, Enter, type. It deliberately does NOT open the next editor: Enter again does that, and the pair reads as "done, next" rather than trapping you in an editing mode you have to escape from. On the last row it commits and stays.

The guards are the part that could have gone wrong. A native select and a date input do their own thing with the arrows and they have to win, so nothing moves while any editor, panel, menu, picker or drag is live, or while anything anywhere is focused that a person could be typing into. Probed against all eight editable column types, the create panel with its own name field focused, the table search box, and a mid-drag row with the drop indicator showing: in every one of them the selection did not move and the editor stayed open. In the chart the arrows are not intercepted at all (defaultPrevented is false), so the timeline keeps the browser's scrolling: the chart's selection is a multi-select set with an anchor and modifier rules, and giving it arrows is a different feature with different questions.

The two arrow meanings never race because Alt is checked first: with Alt the plan changes, without it only the selection does. Verified in one sequence, that an Alt+Down reorders while the focus stays on the row it moved, Ctrl+Z puts the order back, and a plain Down then moves the selection with the order untouched.

Fourteen groups of checks, all passing, zero errors. The 55-check keyboard and drag sweep and the 35-step fixed-column battery both come back identical, and the 52-key fingerprint is unchanged from 0.18.2 apart from the version string.

Build notes, 2026-08-10 (0.18.2, the table is a grid again)

User report: "columns in table view seem to resize depending on where I click." They were, and the measurement is unambiguous. The table sat in table-layout: auto until the user's first manual resize, and renderTable() replaces the whole tbody on a click, an edit, a sort, a filter or a new row. Auto layout re-solves every column from the body content on each of those, so the widths were being recomputed constantly: captured across 35 interactions on 0.18.1, the header widths alternated between an equal split of 206.88px and the content-driven [429.84, 172.78, 108.25, 116.56, 116.56, 120.09, 402.8, 188.11, 30], flipping on 22 of the 35 steps. That is a visible jump on almost every click.

I had seen this exact alternation while verifying 0.16.0 and wrote it off as a headless measurement artifact because it reproduced identically on the pre-refactor build. It reproduced identically because it was a real bug that predated the refactor. Worth remembering: "the same on both builds" only rules out a regression, it does not rule out a defect.

table-layout: fixed from the first paint, always. Under fixed layout the browser sizes columns from the header row alone and nothing in the body can reach them. .fixed-cols no longer means "use fixed layout"; it now means "the user has set widths of their own", which is the only thing it should ever have meant.

The default sizing rule is about column TYPES, not about content. Name 23%, Notes 22%, Provenance 14%, Phase 10%, Owner 8%, Status 8%, Start 7.5%, Deadline 7.5%, delete 30px. A name and a note are prose so they are wide; dates, owner and status have values of known shape so they are narrow; the delete column is an icon. The same nine numbers for every plan and every data set, so the grid a person learns is the grid they get next time. Percentages rather than pixels so it fills the pane it is given; the first drag calls the existing snapshotColWidths(), which freezes every column to its current pixel width, and the table is pixel-driven from then on. That is the Excel bargain: before you touch anything the columns are what the sheet says, and the moment you drag one they are numbers you own, in prefs.tableCols, exactly as 0.15.0 already stored them.

The scrollbar gutter is reserved. With the layout fixed, 33 of 35 steps went identical and the two that did not were the owner filter and the text filter. Both shorten the list enough to take the vertical scrollbar away, which makes the pane 15px wider, which grows every proportional column by a fraction. A width change with no cause the reader can see is the same complaint in a smaller size, so scrollbar-gutter: stable reserves it whether or not there is anything to scroll.

Two smaller things fell out. The .trunc spans dropped their 340px and 300px caps for max-width: 100% in every mode, so ellipsis happens at the column the user has rather than at a number from the auto-layout era, and the font-scaled variants of those caps went with them. And the resize grip moved from right: -3px to right: 0, because cells now clip at the column in every mode and the grip was straddling the boundary with 3px of itself outside the header; it is 8px fully inside now, so it is a bigger target rather than a clipped one.

Verified as an invariant, not as a spot check. Thirty-five captures of all nine header widths across: opening and cancelling an editor in all eight editable column types, typing 120 characters into a notes editor, hovering and unhovering a row, three sort states plus a second column plus back to plan order, an owner filter, a text filter, clearing filters, creating a row with a deliberately enormous name, owner and note, deleting it, undoing the delete, and scrolling the pane and back. All 35 byte-identical. Then the same battery at 150% text scale (30 captures, identical, and at the same widths, because pane-relative percentages do not move when the type does, which is the right answer: a column layout should not jump because someone changed the text size). Then after a real grip drag, which moved Name from 380.64 to 501 for a 120px gesture and persisted 501 to prefs.tableCols: 30 more captures, identical. Then a double-click on the Notes grip, which fitted it to content and clamped at the existing 720px maximum, persisted. Then a full page reload in the same browser profile: the persisted pixel widths came back exactly, and 30 more captures, identical. Zero-shift editing re-verified on top of all of it. No horizontal overflow on the body, the table scrolling inside its own container, and zero JS errors throughout.

The fingerprint moved on purpose, for the first time in five releases. Its colWidths key goes from the content-driven [430, 173, 108, 117, 117, 120, 403, 188, 30] to the type-based [381, 166, 132, 124, 124, 132, 364, 232, 30]. That key is the change. Everything else in the 52 is still the 0.15.1 baseline apart from the version string.

Build notes, 2026-08-10 (0.18.1, the New panel remembers the last one)

Adding six tasks for the same person in the same week meant retyping the owner and both dates six times. A successful Create now leaves those three values behind, and the next New panel opens with them filled in.

In memory, and nowhere else, on purpose. One module-level let, cleared by a refresh. Not localStorage, not prefs, not the sidecar, because this is a fact about this tab in this sitting: it is not a property of the plan, it is not something to hand to whoever opens the plan next, and a value that outlives the sitting stops being a convenience and becomes a wrong default nobody remembers setting.

Name and notes always start empty, because they are what makes an item that item and carrying them over would be offering to create the same row twice. Status keeps its not-started default, provenance keeps its own, and phase is untouched.

Only a create that produced a row updates it. The assignment sits past the name-required guard and next to the push into addedItems, so Cancel, Escape and a create rejected for a missing name all leave the remembered values alone. Verified all three.

Milestones, stated. The panel hides the start field for a milestone but the input keeps its value, so the remembered start comes through a milestone unchanged while its date lands in the same field a task's deadline uses and is remembered from there. That needed no special case, and it is what the panel looks like it is doing: the field you filled in is the field that is remembered.

A prefilled field with no explanation is a confusing field, so the owner label carries "from your last new item" only while values are being carried.

Verified headless: a first open is pristine with today and today plus fourteen and no hint; creating Caleb / 2026-09-02 / 2026-09-25 and reopening prefills all three with name and notes empty and status and provenance at their defaults; a second create with Dana wins; typing Mallory and hitting Cancel leaves Dana; a create blocked for a missing name leaves Dana and creates nothing; a milestone keeps its date and owner and leaves the remembered start where it was. Then the store assertions, done properly rather than against an empty store: the app is made to write its prefs for real, the page is RELOADED in the same browser profile, and localStorage then holds exactly one key whose contents are the seven view preferences with no owner and no date anywhere in it, the sidecar has no key for this, and the panel is back to empty defaults. The 55-check sweep and the 52-key fingerprint are unchanged. Zero JS errors.

Build notes, 2026-08-10 (0.18.0, bars snap to the visible grid)

Dragged bars used to land wherever the cursor stopped, which put them at meaningless fractions of a cell and made the chart look misaligned against its own grid lines. They now land on the lines the axis is drawing.

One rule, not five. The grid, the axis cells and the bar geometry are already the same function of COL: unit i sits at exactly i*COL px and at exactly unitDate(i). So a snap is a rounded index and nothing more, and it follows the active grain for free: day boundaries at the day grain, week starts at the week grain, month, quarter and year firsts at the coarser ones. Nothing had to learn a per-grain special case, and switching the cell-size picker changes what "snap" means without another line of code.

Rounding, so it jumps at the midpoint. Math.round(x/COL) is what makes the preview move from line to line as the cursor crosses halfway rather than sticking until the boundary is reached, which is the difference between a control that feels magnetic and one that feels stuck.

The preview is the commit, literally. Geometry is redrawn FROM the dates rather than from the cursor, so left is toX(start) and the width is toX(end) minus that. There is no separate "preview position" that could round differently from what gets saved, which is the classic way a snapping implementation ends up one pixel or one day off on release.

Three modes, three different fixed points. Resizing the start moves the start and leaves the end exactly where it was; resizing the end is the mirror; a body drag snaps the START and preserves the DURATION, so the end lands at start plus the same number of days it always had. Snapping both ends of a body drag independently would quietly stretch or shrink every bar the user only meant to slide, which is worse than not snapping at all. With more than one bar selected, the bar under the cursor is the one that lands on the line and the rest move by the same number of days, so the arrangement the user selected survives.

Minimum duration is the first grid line strictly past the fixed edge. Not one whole cell: the fixed edge is fixed, and if it sits mid-cell (because a table edit or the data file put it there) then the shortest legal bar is the remainder. Never zero and never negative, which is the thing that actually matters. In practice that is a full cell when the fixed edge is on a boundary and less when it is not: measured, dragging the end edge far past the start gives 1 day at the day grain, 2 days at the week grain, 25 at the month grain and 55 at the quarter grain, each landing on the first line after a start of 2026-08-07.

Milestones snap the same way, being a single date, and the index is clamped to the chart's own range so a drag past either end stops at the first or last line rather than inventing dates outside the axis.

What deliberately does NOT snap. Anything that sets an explicit date: the table cell editors, the item modal, the batch editor, the project-start shift, and the Alt+Arrow moves from 0.17.0, which move rows and not dates at all. Typing 2026-09-17 into a deadline means 2026-09-17. Verified: a table deadline edit at the month grain commits the typed day, unrounded.

Evidence. Driven headless at the day, week, month and quarter grains, all three drag modes at each. Every committed date lands on a boundary of the active grain and has the right shape for it: a Sunday at the week grain, day 01 at the month grain, day 01 of January, April, July or October at the quarter grain. Durations are preserved on body drags (7 days in, 7 days out, at all four grains). The live samples taken mid-drag are step functions whose every value is an exact multiple of COL, so the snapping is visual and not just a rounding on release. The bar's snapped edge sits 0px from the nearest DRAWN grid line at the week, month and quarter grains; at the day grain the app deliberately draws only month rules (a rule per day is noise), and the visible day grid is the axis cells and the weekend shading, so it was measured against those instead: the axis cells sit at i*COL within 0.553px and the snapped edge is 0.163px from a day-cell edge. Undo restores the pre-drag dates exactly in every mode at every grain, the changelog carries the snapped dates, and the whole 55-check keyboard and regression sweep from 0.17.0 comes back identical. The 52-key fingerprint is still the 0.15.1 baseline apart from the version string. Zero JS errors.

Build notes, 2026-08-10 (0.17.0, the keyboard round)

The focused row costs no new click target. It is the row whose cell editor you touched last, marked with a 3px accent bar inset into the first cell. Giving the table a separate "select the row" gesture would have put a second meaning on the click that already opens an editor, and the one-click editors are the thing this table is good at. So the edit you were already making is what says which row you mean. The indicator is an inset box-shadow rather than a border or a pseudo-element with a width, so it cannot move the row it marks: measured, the focused cell's left edge and width are identical to an unfocused one's.

Alt+Up and Alt+Down, not the bare arrows. The arrows on their own belong to whatever holds the keyboard, and to the scroller when nothing does. Alt is free here in both lenses and in the browser chrome. The table moves its focused row, the chart moves its selected task, and a chart selection of more than one row is a different gesture (a batch move) that this deliberately is not.

Crossing a lane boundary moves the lane, because that is the only rule this app has about where a row lives. The group header above a row IS its lane everywhere else, and a keyboard move is not allowed to be the one gesture that breaks it. Stepping down past a header lands at the top of the next lane, stepping up past one lands at the bottom of the previous lane, and the phase, the bar colour and the dashed rule go along, as one undo entry, exactly as a drag does. In the table this looks like a row that changed its Phase cell without changing position, which is correct: the header it stepped over has no table row of its own.

Under a sort, movement is bounded to the tied run, and this was proven before anything else was built. Rows on screen under a column sort are not rows in the plan, which is why dragging is refused there at all. A keyboard move can be allowed because it can be bounded: the row may only swap with a neighbour the sort considers EQUAL to it, so the visible order stays exactly what the sort claims. What actually changes is the tie-break, which is plan position, which persists. The run is computed with the same value function the sort used, extracted out of tableRows for the purpose rather than reimplemented, because a second copy of a comparator is how 0.8.3's export bug happened.

Measured against the exact scenario, on a copy of the live DC plan: owner sort ascending, 15 Adonis rows in one contiguous run containing every Adonis row in the plan; Alt+Down on the middle one leaves the owner column identical top to bottom, swaps exactly those two rows, moves nothing else, and carries the row focus with it; Alt+Down at the bottom of the run and Alt+Up at the top are no-ops that say "the end of the tied group"; under a name sort, where every key is unique, every run is one row and the press says "no other row is tied with this one under this sort". Then clearing the sort: the plan holds the same set of rows, the two swapped their relative order, the moved row now sits immediately after its partner, and removing it from both lists leaves them identical, so nothing else moved. It is in the sidecar's order.

Undo per press, changelog per burst. A held arrow is one intent and eight lines of "reordered rows" is not a log anyone reads, so the changelog write is debounced 600ms and the diff is cumulative, which makes a burst one line naming the row. Undo stays per press, because a keystroke is the unit you want back. Five presses moved a row five slots, produced exactly one key-move changelog line, and took five Ctrl+Z to put back. flushSave now flushes the pending key-move too: 0.8.2 bought the guarantee that the log and the sidecar never disagree, and a 600ms window where a closed tab could drop the line would have sold it back.

Shift+N is gated on the app being idle, which means more than "no editor open". It refuses while any cell editor, label editor, panel, menu, date picker or drag is live, and while document.activeElement is any input, textarea, select or contenteditable, anywhere, including surfaces this round did not write. Probed against all six cell editor types, the create panel's own name field, the table search box, and a mid-drag row with the drop indicator showing: the panel opened in neither lens in any of them, no editor was disturbed, and the typed text survived. Idle in both lenses, it opens. The New button's tooltip now names the key.

Tab walks the rendered cells, not a hardcoded field list. Committing first and then reading the fresh DOM is what makes it follow the visible order under a sort for free, skip the milestone start cell and the delete button (neither carries editable), and stay correct if a commit re-sorts the row out from under itself. startCellEdit already owns the ready-to-type conventions, so tabbing into a select opens its list and tabbing into notes puts the caret at the end without a second implementation. preventDefault runs inside native selects and date inputs as well, or the browser's own focus walk carries on from the control being thrown away and lands in the chrome. Wrapping goes around the ends of the table rather than dead-ending. Verified: all eight editable fields of a row in DOM order then on to the next row's name, the reverse from the last field, commits sticking at each stop (an owner and a note typed mid-walk are both in the row afterwards), both wraps, and a sorted walk landing on the next VISIBLE row.

Nothing rendered moved. The 52-key fingerprint from 0.16.0 is identical to the 0.15.1 baseline except the version string, and the behavioural sweep re-checked the 0.15.0 and earlier work alongside the new keys: row drag with its indicator and its undo, collapse and expand with zero cap overlaps, the eight column grips, delete with undo restoring the row and its name, and header widths unchanged while an 80-character value is typed into a cell. Zero JS errors throughout.

One measurement note for whoever comes next. Headless Chrome under --virtual-time-budget reports table column widths as the pre-layout equal split on alternating measurements, on every build including 0.15.1. It is the harness, not the app: the settled widths reproduce the recorded fingerprint exactly. Measure the during-edit widths against the before-edit widths taken in the same layout pass, or warm the table up with one open-and-cancel first.

Build notes, 2026-08-10 (0.16.0, the init() refactor)

The rule this file kept restating is now enforced by the shape of the code. Three times a statement in the module-level prologue ran before a const further down the file existed and the whole app died in its temporal dead zone: STATUS_ORDER in 0.11.0, then collapsed and prefs in 0.14.0. The advice that came out of those ("anything the prologue touches must be declared above it") was correct and useless, because it asks every future edit to hold a 200-statement execution order in its head, and the failure mode does not look like an ordering bug: the module throws part-way through the build, so the labels are in the DOM and the bars are not, and the symptom reads as a rendering problem.

The module body now contains declarations and function definitions only, plus one await init() on the last line. Every imperative block was wrapped in a named function where it already sat, and the 51 wrappers are called from a single ordered list in init(). Function declarations hoist, so file position stops mattering: by the time any step runs, every declaration in the module has been initialised. The bug class is gone rather than documented.

Four initialisers were genuinely order-dependent and are now declare-then-assign. W needs computeRange() to have set UNITS; lastSnap needs the plan loaded and its undateable rows dropped; lastPaneW/lastPaneH and lastRelayoutW are pane measurements, and a measurement taken before the chart is in the pane is a measurement of nothing. Each is declared bare and assigned inside its own step, at the point in the sequence where it used to run. That is the pattern for anything order-dependent from here: the declaration is a binding, the ordering lives in init().

Nothing was reindented and nothing was reordered. The wrappers are pure insertions, which keeps the diff to what it is (201 added lines, and the only 15 removed lines are one-liners that had a declaration and a statement sharing them, split across two lines). It also matters for correctness beyond taste: the HTML snapshot exporter finds three pieces of this file by exact-text regex, including the whole try{const r=await fetch('state')...}catch(e){} block on one line, and a reformat would have silently produced a snapshot that still tried to fetch from a server it does not have. All three anchors were asserted present after the transform, and the exported snapshot was rendered from file:// to prove it.

The acceptance bar was a zero-diff fingerprint, and it was met. A 52-key capture (76 label rows, 62 bars, 9 caps and their tops, 56 arrows, 71 edit buttons and table rows, control positions, bar geometry to the sub-pixel, axis labels, computed styles, table headers and widths, the first six rows, and the /state, /data, /export/{json,csv,svg} responses) was taken before the refactor and again after: identical on all 52 keys. The harness was validated by re-running it against the pre-refactor file through the same pipeline first, which reproduced the baseline exactly, so the comparison is measuring the code and not the method.

On top of that, a behavioural sweep against a copy of the live DC plan compared before and after on 30 checks and found them identical on 28: the two that differ are the snapshot's byte length and the fact that it now contains await init();. The sweep covers the view toggle, an owner edit round trip with Ctrl+Z, drag arming and its Escape, collapse and expand with the cap collision layout measured at zero overlaps throughout, two font-scale steps and the reset, Fit, the today tooltip, and the standalone snapshot rendering from disk. Zero JS errors, with unhandledrejection captured as well as error, and bars > 0 asserted explicitly because that is the shape the TDZ failure took.

A static invariant now exists for this. Two properties are checkable from the AST and were run against the result: every top-level statement is a declaration except the final await init(), and no top-level declaration references a top-level binding declared later in the file. If a future round wants a guard rather than a habit, that is the check to wire up.

Build notes, 2026-08-07 (0.15.1)

Owner and status moved above the dates in the create panel, and the SAME move in the full edit modal. Two panels doing the same job that disagree about field order is worse than either order on its own, so consistency won over leaving the modal alone. Both now read: name, owner and status, dates, notes, provenance. Tab order is DOM order, so it followed for free and is asserted.

Shipped as its own patch rather than folded into the next round. 0.16.0 is the init() refactor and its whole value is being behaviour-neutral and bisectable, so a visible layout change does not belong in it; and holding this behind a refactor would have delayed a small thing for no reason.

Build notes, 2026-08-07 (0.15.0)

The stray teal line and the lagging line were the same bug. The drop indicator is absolutely positioned with left:0;right:0, and .labels-col had no position: relative, so it was laid out against a container further up the tree. That is why it spanned far past the label column as a stray fragment, and why its top was measured in a different coordinate space than the cursor maths that placed it, which read as lag. One declaration fixes both: the rows and the indicator now share the label column as their offset parent. Measured off-by-0 from the cursor-nearest slot boundary at scroll 0, 300 and 700, and at 150% text scale, with the line always inside the column (271px wide against a 272px column, where before it ran across the app).

Cleanup got its own choke point. hideDropIndicators() is called from the drop, from Escape, from a drag that ends over no valid slot, and from a safety net on mouseup. The chart's task reorder also gained an Escape path, which it never had: it could only be ended by releasing the mouse.

Edit button instead of the double-click. The double-click upgrade was unreachable in practice because the single click mounts the name editor instantly, which is the behaviour the user wants kept. So each task and milestone row now carries a small Edit button at its right edge, visible only under the cursor, absolutely positioned so it cannot move the name text or change the row height (asserted). The dblclick path is kept because it costs nothing and is now a shortcut rather than the only way in.

Group rows deliberately get no Edit button. A lane header's only field is its name, and that name is the phase, which is a different thing to edit than an item. They keep their chevron.

Resizable table columns. Same idiom as the name-column divider: drag to resize, double-click to fit the widest value, widths in prefs. The first resize snapshots every column's current width and switches the table to fixed layout, because auto layout treats a width as a suggestion and re-solves the whole row, so dragging one column would shove its neighbours. Fixed layout makes the number you dragged to the number you get.

Truncation follows the column once widths are user-set: the .trunc spans drop their fixed px cap for max-width: 100%, so Notes and Provenance ellipsis at whatever width the user chose rather than at a hardcoded one. Minimum 56px, maximum 720px, and double-click-to-fit clamps to that maximum, which the Notes column reaches.

Build notes, 2026-08-07 (0.14.0)

The label column edits the name; the bar still owns the schedule. A single click on a task or milestone label drops an editor straight into the row, mounted in the same tick (measured 0ms), focused with the text selected. Double-clicking upgrades to a full editor covering every field, carrying whatever was already typed across, so the fast path never pays for the slow one. Deferring the single click to wait out a possible double would have made plain renaming feel sluggish, which was the one thing the brief ruled out.

Modifier clicks keep their old meaning: ctrl and shift still select and never start an edit. The editor is positioned inside the row's own box so the name column cannot resize, the same rule the table cells follow.

Dropping a task across a lane boundary changes its lane. On the chart the group header above a row IS its lane, so a row that lands under a different header has moved lanes, and the bar recolours to match. The phase restore is folded into the order snapshot already being pushed, so the whole gesture stays one Ctrl+Z.

Dragging a lane collapses every lane for the duration. Choosing a position among five headers is easy; choosing one among seventy rows means aiming at something off-screen. The prior collapse state is captured before and restored exactly on drop or cancel, and the transient state is never written to prefs.

Collapse is view state, in prefs beside theme and grain, because it is how one person is looking at the plan rather than a fact about it. The chevron sits at the far right of each header with the row count, and its tooltip says the collapse is yours alone. If it should ever be shared it moves to the sidecar and the copy changes with it.

Collapsing hides rows, not time: the axis is a function of dates and does not move, and Fit still fits the whole span. Everything that measures rows had to learn about it: dependency arrows skip a collapsed endpoint rather than drawing from nowhere, the milestone cap collision layout ignores hidden caps and their dashed rules go with them, and the tailroom measures the last VISIBLE row. Verified: 67 rows to 42, arrows 56 to 31, caps 9 to 8, zero cap overlaps, zero orphaned arrows, and an exact restore on expand.

Two temporal-dead-zone bugs, the same shape as 0.11.0's. The collapse block was first placed after the chart build, so applyCollapse() ran before const collapsed existed and the module threw straight after the row loop: the labels were in the DOM but the bars container had never been appended, which is why the symptom looked like missing bars rather than an error. Moving it up hit the same wall against prefs. It now sits directly below the prefs block. This file has a long module-level prologue and that is now twice it has bitten; anything the prologue touches must be declared above it.

Build notes, 2026-08-07 (0.13.0)

One click leaves a usable editor, not just a rendered one. Selects and the owner datalist now call showPicker() synchronously inside the click that opened them, feature-detected and wrapped, so a browser without it (or a call the browser declines) degrades to a focused closed control with nothing on the console.

Caret choices, stated: short identifying values (name, owner, provenance) select all, because they are usually replaced wholesale and typing should overwrite. Notes put the caret at the end, because notes are prose you add to and select-all would destroy them on the first keypress. Date cells focus but deliberately do NOT force the native calendar open: it covers the rows below, takes the keyboard, and a date edit is usually a small nudge that is faster typed. The picker icon is one click away for anyone who wants it.

Escape is a stack now, innermost first: an armed drag, then an open cell editor, then the create panel, then selection and menus. A native select or datalist popup swallows its own Escape before any of this runs, which is the first rung for free. Cancelling blurs before re-rendering so focus is released rather than orphaned on a node about to be discarded, and the editor unmounts completely, leaving no editing flag behind.

Compact: bake live edits into the base. POST /compact writes the merged plan (renames, phase moves, created rows, deletions, statuses, order) into the data file and empties the sidecar, because the sidecar is now saying the same thing the file says. One loud changelog event marks the line.

This exists because the sidecar keys rows by their ORIGINAL index in the data file, which makes editing that file underneath a live sidecar genuinely unsafe: removing a row renumbers every key below it and silently re-points every override. Compacting resolves that once, after which a structural edit is just an edit. The changelog is append-only history and is NOT touched: earlier events describe the plan as it was, which is what history means. Proven lossless before use, on a copy of the live data: merged-before and merged-after were byte-identical.

Build notes, 2026-08-07 (0.12.0)

Drag to reorder. Arming is a 350ms click-and-hold, which is what let every existing click survive untouched: opening a cell editor, hitting delete, sorting a header and dragging a bar edge are all short clicks and none had to change. Once armed the row lifts, a drop indicator shows where it will land, the pane auto-scrolls within 44px of either edge, and Esc abandons the whole thing.

Dragging is offered only in plan order. Under a column sort the rows on screen are not the rows in the plan, so an indicator between two of them would describe a position that does not exist. Rather than reordering the plan to match a gesture made against a sorted view, or flipping the sort off on mousedown (which moves the row out from under the pointer before the drag has started), the toolbar says why and offers the way back. Column headers became three-state for this: ascending, descending, then plan order, so the route back is the control already in reach.

A cross-lane drop sets phase and position together as one undo and one changelog line, because the reorder event is already suppressed when a phase event explains it. A same-lane drop reports as a reorder naming the row that moved, via a hint the drag leaves for the differ to pick up.

Lane drags are a chart gesture, because group headers only exist in the chart label column: the table is a flat list with the lane in a column. Dragging a header takes its whole block with it, one move, one undo, one entry. phases[] itself is NOT reordered: a phase index is written into every item and into the sidecar, so renumbering lanes would silently re-home every row in the plan. Only row order changes, which means the legend can list lanes in a different order than the chart draws them. That is the honest trade, and it is noted in the UI copy.

One bug this surfaced. The guard stopping a drag from also counting as a click was a sticky boolean set on mouseup. A drag ending outside the table never produces a click there, so the flag stayed armed and ate the next legitimate click. It is a timestamp now, which cannot go stale.

Dates read 17-Aug-2026 everywhere a person looks. One formatter, used by the table cells, every tooltip, the milestone tags, the header start button and the changelog markdown. A year-month value has no day so it stays Aug 2026, which is what the month and quarter axis bands already showed; the day-grain axis keeps bare day numbers under a month band, since a full date cannot fit an 18px cell and the band above already names the month.

Machine formats are deliberately untouched and stay ISO: the data file, the sidecar, the changelog event payloads and CSV. CSV in particular is interchange, and a display-format CSV would break the round trip. The event timestamp in the markdown log also stays ISO-8601, because it is a machine timestamp rather than a plan date; that is the only ISO string left in the rendered log.

Sorting still sorts on the underlying value rather than the formatted string, which is the classic bug this change invites. Verified chronological both directions.

Build notes, 2026-08-07 (0.11.1)

The rev chip left the canvas. It was a fixed-position chip in the chart's bottom-left corner, and on a tall plan it sat on top of the milestone labels. The rev is machinery for the AI handoff loop rather than something a reader needs on screen, and it stays fully available at GET /changelog (and ?format=md). Its hover text (the event count plus a pointer to the readable log) went with it; that information is in the endpoint's own response. The header's meta line now carries the app version instead, in --text3, which is legitimate here because it is chrome for orientation and not text anyone has to read.

The version comes from package.json, substituted into a placeholder when the server hands out gantt.html, so the header cannot drift from the released version the way a hardcoded string would. Opening the file straight off disk leaves the token unsubstituted, so it is pattern-checked before being shown and simply omitted otherwise. The HTML snapshot export goes through the same path.

Inline editors no longer resize their column. They were replacing the cell's content, so the column re-laid-out around whatever was being typed, and a select is as wide as its widest option, which made the whole table jump on every click. The editor is now an absolutely positioned overlay over the cell: out of flow, so the column cannot feel it, with the cell's own content still underneath holding the intrinsic width. min-width came off the input. Measured across all eight editable columns: header widths identical before, during and after, including with 80 characters stuffed into a text field.

The tooltip stopped following the cursor. There was a mousemove handler re-positioning it on every move while visible, and the reveal itself used pointer coordinates captured 500ms earlier. It is now anchored to the trigger's rect, positioned exactly once at reveal, flipped above when it would fall off the bottom, and clamped on both axes. It leaves on mouseout, mousedown, click, scroll (capture, so inner scrollers count), resize and blur, and it keeps pointer-events: none so it can never intercept anything. Fixed at the shared choke point, so table cells, chart bars, milestone caps, the today tag and the legend all inherit it; all verified, including clamping for the last row of a scrolled table.

Build notes, 2026-08-07 (0.11.0)

One filter line. The legend and the table's filters were two stacked bands saying the same kind of thing (what am I looking at). The table tools now move into the legend row at startup and sit right of the chips, and the row is nowrap. Below 1150px the phase chips drop their words before any control wraps: the colour and the group headers already say which lane is which, and the full name is on the chip's tooltip. Measured single-line by vertical centre (spread 0) at 1600, 1280 and 1100 with no overflow. The gantt legend is unchanged, one line as before, with the tools hidden.

New. One teal-filled button in the header opening the same inline editor panel the milestone editor uses. It sits second from the right of the right-aligned group, behind a fixed-width control, so its position is as stable as the last element's: identical rect across view, milestone-style, theme and font-scale switches. Teal is now reserved for it: the active view-toggle segment gave up its teal fill and reads as selected through an elevated fill, accent text, weight and an accent underline instead, so there is exactly one teal-filled control in the chrome, which is what the brand rule asks for.

Identity for created rows, the subtle part. #row<n> is the data file's key namespace, and it grows, so a created row must never land in it. Created tasks key on a generated slug id; created milestones have no id and get #new-<base36 time>-<counter>, a namespace #row<n> can never reach. On the server rowKeysOf now reads r.id || r._key || '#row'+i, so data-file rows are numbered exactly as before and created rows carry their own key. Created rows are appended after the keys are computed, which is what keeps the existing numbering stable.

Created rows live in the sidecar as added: [...] and are merged by the client and by mergedData, so json, csv, svg and html all contain them. They are ordinary rows once inserted: editable, renamable, phase-movable, deletable, pickable as a dependency. Deleting one that was never in the data file removes it from added rather than recording it in deleted, so the sidecar does not accumulate added/deleted pairs; verified tidy after both a delete and an undo-of-create. An older client ignores added and simply does not show those rows.

Status is four states: not started, in progress, blocked, complete. The old vocabulary is aliased on READ everywhere (live to in progress, planned to not started, hyphenated or spaced), so the data file, the examples and a sidecar the user is mid-edit in all keep working untouched, and only a fresh save writes the new words. Verified: an example still carrying live/planned renders as the new vocabulary with the sidecar provably unmodified on disk, and CSV export emits the canonical words while import accepts either. Colour follows the brand semantics: accent for in progress, --warn for blocked, --ok for complete, grey for not started; red stays destructive-only. A status that arrives from a CSV and is none of the four renders as itself in an italic neutral chip and is offered in the edit dropdown for that row only, rather than being silently coerced or dropped.

The DC data file was migrated by the same kind of targeted pass as the provenance round: 58 statuses changed, and the diff asserted nothing else moved.

One bug I introduced and caught. Hoisting aside, snapSemantic() runs at module level and now calls statusOf, which put STATUS_ORDER in its temporal dead zone and killed the whole app at boot. The status block is declared with the row-key helpers at the top now. Worth remembering that this file has a long module-level prologue, so anything it touches has to be declared above it.

Build notes, 2026-08-07 (0.10.0, editing the plan itself)

Names, phase and deletion are editable from the table, so the table stops being a read-mostly lens and becomes a way to reshape the plan.

Names. My earlier "names are not editable" amendment was correct when it was written and is now obsolete: milestones were keyed by name, so a rename orphaned their state. Stable #row<n> keys retired that reason in 0.8.1, so names are editable for tasks and milestones. A rename restates every surface that repeats the name: the chart label row, the bar's tooltip title, the milestone's label row, hit strip and dashed-rule cap, and the cap collision layout, because a longer name can need another row. An empty name is refused rather than accepted, since a row with no name cannot be identified in either lens.

Phase. A dropdown of lane names. The important half is not the field: a group header is a row like any other, so changing only item.phase would leave the row sitting visually under the lane it came from. moveRowToPhase re-homes it under the target group's header, recolours the bar or the dashed rule, and re-runs the filter so the legend still governs it in its new lane. One undo step covers the field and the move together. A phase move necessarily reorders rows, so the reorder event is suppressed when a phase event already explains it.

Delete. One click, undoable, with a toast naming what went. No confirm dialog: a speed bump in front of something Ctrl+Z already covers is not a safety net. Deleting drops the row from items, its DOM, its bar entry, and every dependency pointing at it, so the arrows, the pickers, the exports and the SVG all stop naming something that is no longer in the plan.

The subtle part is the sidecar. applyOrder is all-or-nothing precisely so a stale key can never silently shrink the plan, which means "deliberately removed" and "key no longer resolves" must not look alike. So deletion is an explicit deleted: [key, ...] list, applied BEFORE the order, with the order length compared against what is left. Verified both directions: a sidecar with deletions loads to exactly 71 of 73 rows, and a sidecar with a genuinely stale key in its order still has its deletions applied while the order is safely ignored and nothing is lost. A bogus key in deleted is a no-op. On the server the row keys are computed BEFORE anything is removed, because filtering first would renumber every surviving row and invalidate every saved key.

An older client reading a sidecar with deleted keys ignores the field and shows the rows, which is the documented acceptable degradation. Verified against the published 0.9.0 build: no errors, all 67 rows.

One bug this surfaced. setView('gantt') re-laid-out the chart from a raw requestAnimationFrame, which is not reliable, and it had been getting away with it. Renaming or deleting a milestone from the table exposed it: the caps came back overlapping because the relayout never ran. It now goes through the app's own debounced path with the echo guard invalidated, which is the same fix 0.8.6 needed for the text scale. That is twice now, so the rule is: never re-measure from a raw rAF in this app.

Build notes, 2026-08-07 (0.9.0, provenance)

A new optional per-item field, provenance, and a Provenance column in the table saying where each row came from. Accepts {label, url} or a bare string, so "Drew added this" is as valid as a link to the plan of record.

The url is user-controlled, which makes this an injection surface. Only http and https ever become an href: safeUrl regex-gates the scheme and then runs it through new URL(), which also kills protocol-relative //evil.example and anything smuggled past the regex. Everything else falls through to the same chip rendered as inert text, so a javascript: provenance shows its label and does nothing. Labels go through esc() like the rest, and the chart tooltip renders it as a text node. Verified with javascript:, data:, protocol-relative and an <img onerror> label: zero anchors created, zero script execution.

A pleasant side effect of normalising through safeUrl on the way into the sidecar: a hostile url does not survive a save. It is stripped to a label-only provenance the first time the row is written.

Editing is the label only. A url is something you paste from somewhere, not something you retype in a grid cell, so it is preserved underneath a label edit and cleared only when the label is cleared. Sidecar gains prov (additive), the changelog gains a provenance action, undo covers it, and CSV export writes provenance and provenance_url as two columns so an export re-imported keeps the label and the link as separate things.

Data. All 73 DC items carry provenance, added by a targeted transformation that asserted afterwards that nothing but the provenance key had changed. 66 are POR; the 4 DoW tasks point at the #adom DC-trip thread; hw-pcb-reflow points at the exact message that raised it, located with adom-google chat read (thread WMWfXbMoR3M, 2026-08-07T14:05:17Z, "we might need a temp reflow solution.. we have the hot plate"), not the thread anchor that was guessed for it. Travel provenance went to log-travel and the "Fly DFW to DCA" milestone only: the reception and show-floor milestones are the plan of record's own event section, not a travel note, so pointing them at the travel chat would have been less accurate, not more.

Build notes, 2026-08-07 (0.8.7)

The text-size control moved from the left cluster to the far right of the header. The left was doing too much: brand, toggle, text size, then the project. It is back to brand, lens toggle, project.

The control had to keep its position invariant, and the right-hand group is right-aligned, which inverts where "stable" lives: in a left-anchored row the first element is fixed, in a right-aligned one it is the last. So it went in as the final child of .actions, past the help button. Its right edge is now the header's own right padding, and everything that hides or changes width (milestone style, theme, the button labels below 1460px) sits upstream of it where it cannot reach. Measured 16px from the header's right edge at 1600, 1280 and 1100, and the rect is identical across milestone-style, theme and view switches and while stepping the scale itself.

Left cluster is 112px narrower: it used to run to 379px, it now ends at 267px.

Build notes, 2026-08-07 (0.8.6, user feedback)

The header named one project three times. Brand carried an app-name and a second subtitle, then the subject carried the title, then a unit count. The brand now carries the app identity only (icon plus app-name, capped at 20ch so another data file's name cannot move anything), and the project's subtitle moved to sit with its title, where a subtitle belongs. So: one title, one subtitle, one small app identity. Dropped the brand's copy rather than the subject's because the subject is where the project is actually named, and the brand should say what the app is, not what the plan is. The brand tooltip now says "adom-gantt, "; the full title and subtitle live on the subject's tooltip.

The header was then too crowded for its own contents at 1280 and below, so it sheds chrome instead of truncating the project's name: under 1460px the unit count, the "starts" label and the button labels go (icons and tooltips stay), under 1180px the subtitle goes too. The subtitle also absorbs all flex shrink before the title gives up a single character. Verified no ellipsis at 1600, 1440, 1280 or 1100.

Text scale. A-minus / A-plus with a percentage readout that doubles as the reset, seven steps from 85% to 150%, persisted and applied before the chart is built. One CSS variable drives every text size plus the row, bar, axis and diamond geometry that has to grow with it, so nothing is rebuilt: the browser reflows and the JS re-measures. Applies to both lenses, because it is a statement about the reader, not about the chart. The control itself does not scale, and the readout has a fixed width, so pressing it never moves it.

Two things had to be fixed to make it correct rather than merely bigger. The dependency arrows put their endpoints at offsetTop + 17, which was half of a 34px row hardcoded, so every arrow was wrong the moment rows grew; they measure the row now. And the re-measure after a scale change was silently skipped: scheduleRelayout has an echo guard that ignores a resize reporting the same pane width and height, which is exactly this case, so the milestone caps kept their old row assignments and overlapped at 150%. Invalidating the guard before scheduling fixes it, and it goes through the app's own debounced path rather than a second bespoke one. Measured: zero cap overlaps at 85%, 100% and 150%, caps still clear of the today tag, arrows on the row midpoint at 150%.

Today has a tooltip. Hovering the today tag says "Today" and the date, recomputed on hover so a plan left open across midnight is not lying. The legend's Today entry carries it too. The line itself stays pointer-events: none on purpose: a full-height hit strip would sit over every bar it crosses and eat their clicks, which is a worse trade than a tooltip, so the tag is the affordance and got a little more padding to be a comfortable target.

Build notes, 2026-08-07 (0.8.5, two UI changes from the user)

Fit. A button next to the cell-size picker that fits the whole plan into the pane in one click. Zoom here is not continuous: each grain carries a minimum cell width sized to the widest label it draws, and below that the timeline scrolls rather than squashing into unreadability. So fitting to the pane means choosing a grain, not a scale. bestFitGrain walks finest to coarsest and takes the first whose full span clears its own minimum in the width actually available, which is the most detail the pane can hold with no sideways scrolling; fitCOL then stretches the cells to fill it exactly. computeRange was refactored into a pure rangeFor(grain) so Fit can ask how wide a grain would be without switching to it first. It always rebuilds, even when the grain does not change, because the gesture means "use the width I have now" and the pane may have been resized since.

The view toggle moved and grew. It lived inside .actions, which is right-aligned, so every chart-only button that hid in table view made that group narrower and shoved the toggle sideways: the user was chasing it between clicks. It now sits first in the controls row, anchored to the brand block, with the chart-only controls all downstream of it where they cannot move it. It also got icons, more padding and a third active-state difference (weight, on top of fill and text colour).

One thing that fell out of moving it: the brand's subtitle was unbounded author text, and the DC plan's is a full sentence, so it alone pushed the toggle to the middle of a 1600px header and would have run a narrow one off the edge. Capped at 34ch with an ellipsis, full text on the header's tooltip. Toggle now sits at a fixed 385px, measured identical at 1600, 1280 and 1100px window widths and across six control-state changes.

Build notes, 2026-08-07 (0.8.4)

One residual from the 0.8.3 dateless-row work. The viewer, /export/json, /export/csv and /export/html all coped with a hand-corrupted data file, but /export/svg still returned a 500 (Cannot read properties of undefined (reading 'split')), because generateSVG builds its own date range and its own toX and so never went through either of the guards. It now filters with the same rowDatesOk the importers use, and names what it left out in an XML comment at the top of the file rather than shipping a quietly shorter chart.

Two things fixed alongside it in the same function, same defect class: a phase with no usable start or end threw from the phase loop for the same reason, and a file where nothing at all carries a date produced a negative width. Both are guarded; the second falls back to drawing a single empty month.

Verified byte-identical output to 0.8.3 on a clean file, so nothing about the normal path moved.

Build notes, 2026-08-07 (0.8.3, final adversary round)

The server's milestone ladder was missing the key rung the client had. The v3 sidecar carries a stable key, resolveMs in the client resolves by it first, and resolveMilestone on the server did not look at it at all: it started at the name and fell back to the reorder-stale index. Every mergedData() consumer was wrong together, so /export/json, /export/csv, /export/html and the SVG all disagreed with the screen. Measured against a sidecar where a milestone had been renamed and moved past another: the old ladder silently dropped both the rename and the owner, leaving the export showing a name nobody had used since. Ported the ladder verbatim. The two implementations now carry a comment saying they must stay identical, because they are the same algorithm written twice, which is exactly how this happened.

A dateless row bricked the whole chart. Our own CSV importer accepted a task with an empty end or a milestone with an empty date and returned ok: true; on the next load parseISO split an undefined, the exception escaped the build, and the page was dead with no way back but hand-editing the file. Measured on the 0.8.2 client with three bad rows in a 73-row plan: 0 bars, 0 table rows, one uncaught Cannot read properties of undefined. Fixed at both ends, because either alone leaves the hole open: all four importers now run assertDates before they write anything, and the client drops undateable rows at load and says which ones.

Rejecting at import rather than defaulting, deliberately. A defaulted date is indistinguishable from a real one the moment it is in the file: it renders, it sorts, it gets scheduled against, and nothing ever says it was invented. A 400 that names the offending rows costs a minute and cannot quietly become someone's deadline. The client still has to be tolerant regardless, because a hand-edited data file never goes through an importer.

Changelog markdown could be line-spoofed. /log is unauthenticated and takes free-form strings, which were embedded verbatim in ?format=md, so a newline inside a description could fabricate its own - [rev 999] ... admin approved ... line and put words in another actor's mouth. Newlines are collapsed at render time. One event is one line, always.

The brand fonts had never once loaded. The @font-face rules pointed at https://adom.inc/fonts/, which serves the .woff2 files with no Access-Control-Allow-Origin header (verified: 200, no ACAO). A font fetched by @font-face is always a CORS request, so every one was blocked and the app has rendered in the fallback system stack from the beginning. Familjen Grotesk and JetBrains Mono now come from Google Fonts, which sends ACAO: *. Satoshi is not on Google Fonts, and Fontshare's CSS endpoint loads but hands the browser no @font-face rules at all (link.sheet is non-null, zero faces registered), so the two Satoshi weights are declared directly against cdn.fontshare.com, which does send ACAO: *. Verified in a real browser: all three families report loaded, no face errors. The fallback stacks are untouched. The other possible fix is to add the header on adom.inc, which would be better for every Adom app at once and is worth raising separately.

Build notes, 2026-08-07 (0.8.2, behavioural verification pass)

A verifier drove the app as a user and found two bugs that only show up in use.

The chart's tooltip went stale after a reload. updateBar patched the bar's geometry, dates and note but never its data-title/data-body, which is where the hover tooltip reads from. So a description edited in the table and restored from the sidecar on the next load left the bar hovering the version from the data file, while /state and /export/json were both correct. Fixed at the choke point: updateBar now restates the tooltip dataset, which covers the initial state load and every live edit at once, and updateMilestone does the same for the hit strip (diamond style) as well as the cap (line style), which had the same gap.

A tab closed inside the save debounce lost the edit. The changelog entry goes out immediately but the sidecar write is debounced 800ms, so closing the tab inside that window left the log claiming an edit the sidecar never received, which is exactly the disagreement the changelog exists to prevent. flushSave now sends any pending state synchronously on pagehide and on the visibilitychange to hidden, via sendBeacon with a keepalive fetch fallback. The debounce is untouched for normal typing.

Two smaller ones taken as well. POST /log with valid JSON of the wrong shape ({"events":"boom"}) used to return 200 and silently discard the events; the log is append-only and permanent, so it now returns 400 for a non-array events, a non-object body, or any event without a string action. And a CSV import used to leave no trace in the changelog at all, even though it replaces the data file wholesale and clears the sidecar: all four import endpoints now record a single import event, which reads as "REPLACED the whole plan ... Everything before this point describes a different plan", so an agent reading the log cannot mistake the history above it for history of the current plan.

Build notes, 2026-08-07 (0.8.1, adversarial review pass)

A fresh-context reviewer went at 0.8.0 and found real defects. All of them were reproduced before being fixed. Nothing was rebutted.

Two that could destroy data or the service

Renaming a milestone deleted it. Row order was persisted as a list of id || label || name, so a rename changed the key, the key no longer resolved on the next load, and applyOrder pushed only what resolved: the plan silently went from 67 rows to 66 and from 9 milestones to 8. The next autosave then wrote the shortened plan back over the sidecar, permanently. Three things were wrong and all three are fixed: rows now carry a stable _key (#row<n>, their index in the data file) that text edits cannot move; applyOrder is all-or-nothing and refuses an order that does not resolve to exactly the same set of rows; and applyState now reads the milestone name back, which it never did, so the rename persists at all. The changelog used to report a rename as a phantom reorder (the name-keyed milestone diff early-returned on both sides); milestone identity in the diff is now the stable key, and a rename emits a real milestone.rename event.

One malformed changelog event killed the server for good. POST /log {"action":"dates","id":"x"} with no from, then GET /changelog?format=md: res.writeHead(200) had already run, describeEvent dereferenced e.from.start, the throw reached the outer catch, jsonResp wrote a second head, and ERR_HTTP_HEADERS_SENT escaped every handler and exited the process. The poison line stayed in the append-only log, so the server died again on every restart. describeEvent is now total (every field goes through a defaulting accessor), the markdown is rendered before the head goes out, the outer catch destroys the socket if a head is already sent, POST /log rejects an event with no string action, and there is an uncaughtException handler as a last resort.

Injection

Stored XSS through the Notes column. The table cell escaped correctly, but the tooltip read the text back out of a data-body attribute (which decodes on read) and assigned it with innerHTML, so esc() was nullified at the sink. A payload typed into Notes fired on hover, persisted to the sidecar, and fired for every later viewer. The tooltip is now built from textContent nodes. Every other innerHTML fed by author or user text was escaped in the same pass: the chart's first-paint label row, the legend, the header, the dependency picker and list, and the editor value attributes (which only escaped quotes, so they also mangled &).

</script> breakout in the HTML export. JSON.stringify does not escape <, so a note containing </script> closed the tag and ran as markup in the exported snapshot. Every < in the inlined data is now \u003c.

CSV formula injection. Owner and Notes cells opening with = + - @ are now apostrophe-guarded, and import strips the guard, so the round trip stays lossless.

Correctness

Duplicate milestone names misrouted edits. ms.find(name) || ms[i]: find always won and always returned the first match, so editing the second of two same-named milestones silently edited the first. Resolution is now a claim-tracking ladder, key then unique name then index then first name match, on the client and the server.

A sidecar entry with no id corrupted a group row. find(d => d.id === sv.id) with sv.id undefined matched the first id-less item, which is a group header, and wrote task dates and an owner onto it, dragging the whole axis back to 2020. Both sides now skip entries with no id.

rev was client-overridable. {rev: ++rev, ...e} spread the caller last, so a posted rev overwrote the counter and broke ?since= monotonicity. Server fields now go on last.

CSV export could not be re-imported. Export wrote the phase index, import reads the phase name, so a round trip produced phases literally called "0" to "5" with everything in phase 0. Export writes the name. Also added: an 8 MB request body cap.

Data

Group labels carry their lane owner again: "Hardware, the long pole (Adonis)", "Test (Noah)", "Logistics (Dan)", "Software & Content (Kyle)", "NSF Coordination (John)". These are lane leads, not task owners; the per-task owner is the granular truth, and the two can differ (Drew owns most Logistics tasks under Dan's lane).

Every dependency date inversion is gone. The reviewer flagged 8; a full sweep found 18 under the rule now enforced: no task may finish on or before any dependency of its own, and none may start more than 3 days before a dependency ends. 32 tasks were re-dated. All 9 milestones keep their original backward-scheduled dates, and the tasks that feed them still land on them: workcells, PCBs and Keysight by 8/27, dry run by 9/8 with safety docs at 9/5, ship by 9/15.

Build notes, 2026-08-07 (0.8.0, initial build)

Shipped the table view (0.8.0): a second lens over the same items, with owner, deadline and status editable in place, flowing through the existing sidecar and changelog pipeline.

Where the build deviated from the plan

The legend stays visible in table view, and filters it. The plan implied the table would carry its own filters alone. Hiding the legend would have meant a user in the table could not narrow to a phase, and keeping it but ignoring it would have meant the two lenses disagreed about what is in scope. So applyFilter now re-renders the table, and the phase and Milestone toggles apply to both. The Dependencies and Today toggles are chart-only and are hidden in table view (.legend-item.gantt-only), as are the project-start control, the grain picker and the milestone-style button: nothing in the table changes when they do.

Name is not editable in the table. The plan asked for owner, status, deadline and notes; it did not ask for name, and name is load-bearing. Milestones carry no id and are keyed by name in the sidecar, so renaming one from a bulk-edit surface would silently orphan its saved state. Names stay editable where they already were, in the milestone editor.

Start is editable too. It was the same machinery as the deadline and the column was already there, so leaving it read-only would have been an arbitrary gap.

Undo covers table edits. The plan allowed skipping this if the undo stack was not generic. It nearly was: snapshotMany and snapshotMs already captured a per-item record, so they were widened to carry owner, status and description and the restore paths widened to match. Milestone undo now also restores name, which closes a pre-existing gap where the milestone editor could rename a milestone and Ctrl+Z would put the date back but not the name.

Group rows are not table rows. A flat table cannot both sort globally and keep section headers meaningful. Groups are dropped and the phase is named in its own column instead, with the phase colour as a dot, so the grouping information survives in a form that sorting cannot break.

Blank values sort last in both directions. Straight lexical sort puts every unassigned row at the top of an owner sort, which is exactly where it is most in the way.

Sidecar bumped to v: 3, additively. New fields are owner, status, desc on task entries and on milestone entries. Verified: a v: 1 bare task array and a v: 2 object sidecar both still load and apply. An absent field means the sidecar has nothing to say and the data file wins; an empty one means the user cleared it, which is why both the client and the server use a setOpt helper rather than a plain assignment.

Tasks with no id render read-only. The rest of the app already assumes every task has an id (barEls is keyed by it, and an id-less task would collide on barEls[undefined]), so the table shows such rows but does not offer to edit them rather than pretending to persist an edit it cannot key.

Two pre-existing bugs fixed in passing

The HTML snapshot never disabled saving. generateHTMLSnapshot tried to replace the client's saveToFile with a regex, /function saveToFile\(\) \{[^}]+\}/, which could never match: the source has no space in saveToFile(){, and [^}]+ would have stopped at the first nested brace and produced broken JavaScript if it had. So an exported snapshot still POSTed to a save endpoint that is not there. Replaced with a check on the SNAPSHOT flag the exporter already sets, inside saveToFile and inside the changelog POST. The regex is gone.

Two em dashes in user-visible copy (the document title and the batch editor's hint) were replaced, per the Adom brand rule.

Verification

Driven headless against a scratch instance: view toggle, 67 rows from the DC demo plan, deadline-ascending default, owner sort, owner filter (Noah: 8 of 67), Unassigned filter (the 9 milestones), text filter, in-place edits of owner, status, deadline and notes on a task, owner and deadline on a milestone, then back to the chart to confirm the bar had moved, the status tag had changed, the milestone caps still laid out and the 60 dependency arrows still drew. Ctrl+Z put the milestone date back. GET /state returned v: 3 with the new fields; GET /changelog returned owner, status, desc, dates and milestone events tagged via: "table-edit", and the markdown rendering read as sentences.

Known gaps

  • The Import paths other than CSV do not carry owner. MS Project XML and Jira CSV have no obvious single-owner field to map, and guessing one would put a wrong name against someone's work. CSV import and both export paths do carry it.
  • The table does not support multi-select or batch edit. The chart does, and the batch editor is date-only; a batch reassign is the obvious next thing to add.