Adom Gantt
Public Made by Adomby adom
Interactive Gantt chart viewer for project timelines, with a table view over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. Every edit is recorded in an append-only
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
Importpaths other than CSV do not carryowner. 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.
# 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, <appName>";
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.