← Commit history
README.md+18−7
@@ -12,7 +12,9 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc  **Analytics.** A third lens, next to Gantt and Table. Total effort for the plan in hours (working days times eight, a forty-hour week), the working hours LEFT between today and the project end, the AVERAGE hours per assigned person, then one row per person carrying their hours, share and item counts alongside their five status counts, the unassigned bucket included. Status counts overall, per-section totals with the range each section actually covers, milestones with days from today, dependency stats with a cycle warning, and everything overdue. Read-only, whole-plan, and re-derived from the same rows every time you switch to it. -**Three lenses over one plan.** A **Gantt / Table / Analytics** toggle in the header. The chart answers "when". The table answers "whose, and by when": every task and every milestone as a row with Owner, Start, Deadline, Status and Notes, sortable on any column, filtered to one person with the owner dropdown, and editable in place. An edit made in the table is already true on the chart when you toggle back, because there is only one set of items underneath. The choice of lens persists, and `project.view.mode` can pin it for a plan that is an assignment list more than a timeline.+**Setup.** A fourth lens, since 0.58.0, for the plan's structure rather than its work. It is where the sections are managed: add one, rename it and set its aside, recolour it, move it up or down, or remove it, each row showing how many rows the section holds and the dates they span. It replaces the Sections button that used to sit in the header and open a modal. Every change is still one Ctrl+Z and one changelog line.++**Four lenses over one plan.** A **Gantt / Table / Analytics / Setup** toggle in the header. The chart answers "when". The table answers "whose, and by when": every task and every milestone as a row with Owner, Start, Deadline, Status and Notes, sortable on any column, filtered to one person with the owner dropdown, and editable in place. An edit made in the table is already true on the chart when you toggle back, because there is only one set of items underneath. The choice of lens persists, and `project.view.mode` can pin it for a plan that is an assignment list more than a timeline.  **Add rows.** The New button creates a task or a milestone: name, lane, owner, dates, status, notes and provenance. Created rows live in the sidecar, so the data file is never rewritten, and they appear in every export. @@ -72,7 +74,7 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc  **Import.** JSON (native format), CSV, MS Project XML, and Jira CSV. Drag-and-drop or file picker. A file with rows that carry no usable date is rejected whole, naming the rows, rather than having a date invented for it. A row that cannot be dated is left out of the chart with a warning instead of blanking the plan. -**Export.** JSON, CSV, SVG, and self-contained HTML (no server needed to view the snapshot). The Export button serialises what is on your screen right now, not what is on disk, so it reflects unsaved edits and keeps working even when a save conflict has stopped autosave. CSV carries the phase by name, so an export re-imported keeps its phases, and any cell opening with a formula character is apostrophe-guarded so a spreadsheet cannot execute it. Import strips the guard back off.+**Export.** JSON, CSV, SVG, and self-contained HTML (no server needed to view the snapshot). The Export button serialises what is on your screen right now, not what is on disk, so it reflects unsaved edits and keeps working even when a save conflict has stopped autosave. CSV carries the phase by name, so an export re-imported keeps its phases, and a row's tags in a `tags` column joined with `;` like its dependencies, and any cell opening with a formula character is apostrophe-guarded so a spreadsheet cannot execute it. Import strips the guard back off.  **Auto-save.** Edits persist to a sidecar JSON file next to the data file, debounced while you work and flushed immediately if you close or hide the tab, so the changelog never claims an edit the sidecar did not get. Delete the sidecar to reset. Every row in it is keyed by its permanent `uid`, so renaming a row, reordering the plan, or inserting a row into the middle of the data file all leave every saved edit pointing exactly where it was. Sidecars written by older versions keyed rows by position; they are translated on the way in and keep working. The saved row order is applied all-or-nothing, so a key that no longer resolves can never drop a row. @@ -148,6 +150,13 @@ Create a JSON file with three sections:  <!-- prose-lint-enable --> +**Tags** are optional on a phase and on a task or milestone: `"tags": ["Tech"]`, a+short list of short strings. The server trims them, drops duplicates that differ only+in case (the first spelling wins), caps each at 32 characters and the list at 12, and+writes no `tags` key at all when there are none, so a plan without tags round-trips+unchanged. A tag change is saved in the sidecar and logged as `tags` (a row) or+`phase.tags` (a section).+ Omit `color` on a phase and it follows the Adom brand ramp, which adapts between dark and print. See [SKILL.md](SKILL.md) for the full field reference and the ramp values.  ## Sections are phases@@ -159,9 +168,11 @@ so renaming a header renames the phase and the change shows up in the legend, th table's phase column and every phase dropdown at once. Dragging a lane reorders `phases[]` itself, which is why the legend and the chart can no longer disagree. -The **Sections** button in the top bar opens the editor: add a section, rename it,-give it an aside, recolour it from a brand-safe palette (or a `#rrggbb` of your-own), reorder it, or remove it. An empty section goes on its own; one with rows in+The **Setup** tab is the editor (since 0.58.0; until then it was a modal behind a+**Sections** button in the top bar): add a section, rename it, give it an aside,+recolour it from a brand-safe palette (or a `#rrggbb` of your own), reorder it, or+remove it. Each section's row shows how many rows it holds and the first and last+dates among them. An empty section goes on its own; one with rows in it makes you say where they land, so nothing is ever orphaned. Every change is one Ctrl+Z and one changelog line. @@ -348,7 +359,7 @@ table, the item editor or the batch editor are taken exactly as typed.  | Gesture | Action | |---------|--------|-| Gantt / Table / Analytics | Switch lens. Pinned to the left of the header so it never moves |+| Gantt / Table / Analytics / Setup | Switch lens. Pinned to the left of the header so it never moves. Setup is where sections are added, renamed, recoloured, reordered and removed | | Fit | Fit the whole plan into the pane, no horizontal scrolling | | Auto-schedule | Push every task out to its dependencies, finish to start, skipping weekends and US federal holidays. Never pulls work earlier, so intentional gaps survive. One undo. Disabled when the plan has no dependencies | | Critical | Light up the longest run of work inside every section, cancelled work left out. On/off, and it recomputes as the plan changes |@@ -407,7 +418,7 @@ node test/ui_test.mjs --keep --plan path/to/plan.json   # also a real plan (copi node test/ui_test.mjs --only "editor: item,text size"   # a subset of flows, for a fast inner loop ``` -A full run takes about 12 minutes (14 with `--plan`). Since 0.57.1 the suite also opens every editor and menu, sweeps the header at 54 widths in all three lenses, fails on any failed request to the app's own server, and fails when a control marked safe was never clicked. Controls with no test are marked `untested` with a reason and counted in the summary, so the gap is stated and not hidden.+A full run takes about 12 minutes (14 with `--plan`). Since 0.57.1 the suite also opens every editor and menu, sweeps the header at 54 widths in every lens (four since 0.58.0), fails on any failed request to the app's own server, and fails when a control marked safe was never clicked. Controls with no test are marked `untested` with a reason and counted in the summary, so the gap is stated and not hidden.  ## Collaboration 
SKILL.md+55−10
@@ -15,7 +15,9 @@ description: >   milestone as a row with Owner, Deadline and Status, filterable per owner   and editable in place. An Analytics view is the third: total hours,   hours per person, status counts, per-section totals, milestones,-  dependency stats and what is overdue. Trigger words:+  dependency stats and what is overdue. A Setup view is the fourth: the+  plan's sections, added, renamed, recoloured, reordered and removed on a+  page of their own. Trigger words:   gantt, timeline, project schedule, gantt chart, project gantt, show   timeline, view schedule, project plan chart, milestone chart, task   table, who owns what, what is mine, owner, deadline, task list,@@ -114,7 +116,7 @@ preferences entirely so it always opens as authored.  | Field | Values | Description | |-------|--------|-------------|-| mode | `gantt` `table` | Which lens the plan opens in |+| mode | `gantt` `table` `analytics` `setup` | Which lens the plan opens in. Through 0.57.1 only the first two were read and anything else opened the chart | | grain | `day` `week` `month` `quarter` `year` | Cell size | | milestones | `lines` `diamonds` | Milestone style | | today | `true` `false` | Show the today marker |@@ -134,6 +136,7 @@ hero image that invited the click. | uid | string | assigned | The phase's permanent id, from the same counter rows use. The server writes it | | lead | string | no | A short aside shown beside the section name, e.g. "the long pole (Adonis)". Secondary text, never the name | | color | hex string | no | Overrides the brand ramp for this phase. An explicit colour is theme-INVARIANT by design: it is the colour someone chose, so it is the same in light, dark and print. Omit it to stay theme-aware, see Phase colors below |+| tags | string[] | no | Short labels for the section, e.g. `["Tech"]`. Trimmed, de-duplicated without regard to case (the first spelling wins), each capped at 32 characters and the list at 12; anything that is not a string is dropped. Absent means none, and the server never writes an empty list. No colour of their own | | start | YYYY-MM | yes | Phase start month | | end | YYYY-MM | yes | Phase end month | @@ -173,6 +176,7 @@ items. | deps | string[] | no | Array of task IDs this depends on | | note | string | no | Short label drawn on the bar itself, e.g. `6m` or `Y1`. Keep it to a few characters | | striped | boolean | no | Striped bar pattern (for ongoing work) |+| tags | string[] | no | The row's OWN tags, e.g. `["long-lead"]`. Same rules as a section's tags. A row's effective tags are these plus its section's |  **type: "milestone"**: diamond marker, or a dashed vertical rule if the user has switched milestone style @@ -185,6 +189,14 @@ items. | owner | string | no | Usually absent. The table shows "unassigned" and lets you set one | | status | string | no | Usually absent. Same values as a task | | description | string | no | Tooltip body text, and the Notes column in the table |+| tags | string[] | no | Same as a task's |++**Tags ride every export.** `/export/json`, `/import/json` and the HTML snapshot carry+`tags` on phases and items, and every import normalises them. `/export/csv` has a+`tags` column after `deps`, joined with `;` the same way, and `/import/csv` splits it+back; a CSV without the column imports as before. A CSV is rows, so section tags are+not in it. A Jira CSV's `Labels` column becomes each row's tags; MS Project carries none.+A plan with no tags gains no `tags` keys anywhere, so an old file round-trips unchanged.  ### Phase colors @@ -217,10 +229,10 @@ rather than a category. ## Features  - **New** button (or Shift+N): create a task or milestone. After a successful create, the next New panel prefills EVERY field from the one you just made (type, phase, owner, status, start, deadline or milestone date, notes, provenance label). Only the name starts empty, so a run of tasks for the same person in the same lane in the same week is names and nothing else. A provenance left at its default is not remembered as a value, so it keeps following the signed-in identity. It is in-memory for the page session only: a refresh clears it, and it is never written to localStorage, the preferences or the sidecar, so it is never carried to anyone else. Only a create that produced a row updates it: Cancel, Escape and a validation-rejected create all leave it untouched. Created rows live in the sidecar as `added: [...]`, keyed in a `#new-` namespace that cannot collide with the data file's `#row<n>`, and are merged into every export-- **Text size** A-minus/A+ in the header: seven steps from 85% to 150%, applies to the chart and the table, persists in `localStorage` as `prefs.fs`. Row and bar geometry scale with the type. **It survives a refresh in the live app AND in an exported HTML snapshot** (fixed in 0.57.0). Text size is a READER preference, so it is the one pref a snapshot reads back from and writes to the reader's storage; everything else in a snapshot still opens at the author's view, and a snapshot now writes only the size, so it can no longer overwrite the grain, lens or collapse set a reader has stored on the same origin. Open tabs of the app follow a size change made in another tab live. **Every pref is saved merge-on-write**: a save writes only the keys this tab changed since it last read or wrote them, into a fresh read of storage, so a second tab saving its theme can no longer reopen the lanes you collapsed in the first, or reset your text size+- **Text size** A-minus/A+ in the header: seven steps from 85% to 150%, applies to the chart and the table, persists in `localStorage` as `prefs.fs`. Row and bar geometry scale with the type. **It survives a refresh in the live app AND in an exported HTML snapshot** (fixed in 0.57.0). Text size is a READER preference, so it is the one pref a snapshot reads back from and writes to the reader's storage; everything else in a snapshot still opens at the author's view, and a snapshot now writes only the size, so it can no longer overwrite the grain, lens or collapse set a reader has stored on the same origin. Open tabs of the app follow a size change made in another tab live. **Every pref is saved merge-on-write**: a save writes only the keys this tab changed since it last read or wrote them, into a fresh read of storage, so a second tab saving its theme can no longer reopen the lanes you collapsed in the first, or reset your text size **On a page the host sandboxes (0.58.0)**, such as a snapshot uploaded to wiki.adom.inc and served under `Content-Security-Policy: sandbox` with no allow-same-origin, the page has an opaque origin and every reload starts with empty storage. A snapshot therefore also keeps the size in the URL fragment (`#fs=1.2`, written with replaceState), which a reload, a bookmark and a shared link all keep. Storage still wins where it works, so a reader's own size is not replaced by a link carrying somebody else's; the fragment answers only where storage has nothing. - **Project Duration and Time Remaining** in the header, stacked, beside the lens switch (0.57.0, replacing the old "25 weeks" axis-cell count, which changed with the grain and counted the axis padding). Both come from the plan's own dates and never from the grain: Duration runs from the earliest task start or milestone date to the latest task end or milestone date, both days counted, and Remaining runs from today to that last date, today counted. **Cancelled rows are left out**, as Auto-schedule, the critical path and the hours totals leave them out, and the tooltip says how many were; if every dated row is cancelled both read `none` and the tooltip says why. (The start button still counts cancelled rows, because it moves the whole plan. The Analytics "calendar span" card still includes them; it was not changed.) Format: days under two weeks, `16w 4d` style under half a year, then months and years to one decimal. The tooltips give the exact calendar and working-day counts and both dates. Remaining reads `ended` once the last date is past; before the first date it still counts from today, so it can be longer than Duration, and a small `not started` note sits on the readout itself to say why. Both read `none` for a plan with no dated rows. They update on every edit that can move a date (drag, editor, table edit, start shift, auto-schedule, create, delete, paste, undo, redo) and at midnight on a tab left open. The header FITS ITSELF to its content rather than to pixel breakpoints (`fitHeader`): it measures the row and, only while the content is wider than the window, sheds in order the version, the button words and the long labels (Duration, Remaining). If that still does not fit, it wraps onto a second row with the long labels back, and with the button words back too whenever they cost no extra row (they stay off on a phone, where they would). The readouts never shrink, so they are whole at every width; the suite sweeps 360 to 2000px with and without an appName at 100% and 150% text to hold that - **Fit** button beside the cell-size picker: picks the finest grain whose whole span fits the pane, so the plan is fully visible with no horizontal scrolling. Chart-only-- **Gantt / Table / Analytics toggle** pinned first in the header, left of every conditional control so its position never shifts. Three lenses over the same items: the chart answers "when", the table answers "whose, and by when", and analytics answers "how much, and is any of it in trouble". See The table view and The analytics view below+- **Gantt / Table / Analytics / Setup toggle** pinned first in the header, left of every conditional control so its position never shifts. Four lenses over the same items: the chart answers "when", the table answers "whose, and by when", analytics answers "how much, and is any of it in trouble", and Setup is the plan's structure, its sections. See The table view, The analytics view and The setup view below - **Auto-schedule** button, with the plan-level controls next to Fit, in the chart and the table lenses. One press lays the WHOLE plan out under two rules at once: no task with dependencies starts before its latest predecessor's end date, and no task starts or ends on a Saturday, a Sunday or a US federal holiday. The handoff date is a MINIMUM and not an answer: it is BACK TO BACK for a task that was overlapping and is pushed onto it, and back to back is a statement about the CHART, since a bar runs from its start to its end and the end is the boundary it stops at, so a successor starting on that date draws flush against it with no gap and no overlap. **PUSH ONLY, since 0.53.0.** A task that already starts on or after its handoff is LEFT WHERE IT IS, however wide the gap, because a gap in a real plan is usually somebody's lead time: an order placed on the Monday and the parts that arrive on the Thursday keep their three days. Through 0.52.0 the pass went both ways and closed every one of those, and there was no way to keep one. Nothing is ever pulled EARLIER now, so the plan cannot drift backwards over a press, and the pass reaches a fixed point in ONE press: the second moves nothing. Pushes still cascade, each successor measured against where its predecessor LANDED and not against the date it shipped with. **CANCELLED WORK IS NOT IN THE PASS AT ALL,** also since 0.53.0: it is never moved, so its dates freeze exactly as set even if they violate something, and it constrains nobody, so a task whose only predecessors are cancelled behaves like an anchor and one with a mix is pushed by its LIVE predecessors alone. The toast counts those rows as their own third number ("5 tasks moved, 15 unchanged, 1 cancelled excluded") rather than folding them into the count of work that was already aligned. A task with no dependencies is an ANCHOR and keeps its start (shifted LATER off a weekend or a holiday if it was on one), because that is the one thing in a plan this cannot derive. Duration is preserved in WORKING days, not calendar days, so a five-day task laid across a weekend, or across Labor Day, still takes five working days: a predecessor ending on the Friday hands off on the Friday and the weekend falls inside the successor's own span, never between the two. Milestones take the same rule with no duration: off a weekend or a holiday to the next working day, and on the day their last dependency ends. The graph crosses sections freely and so does the schedule; a section's displayed range just follows its rows. A dependency CYCLE is left exactly as it is, along with anything downstream of it, and the toast names the rows: there is no honest answer for a row that waits on itself. The whole pass is ONE undo entry and ONE changelog event (`action: "auto-schedule"`, with a count and the names of what moved), and a press that moves nothing writes nothing. Deterministic. Disabled, with a tooltip that says why, when the plan has no dependencies - **Critical** button, next to it: the LONGEST run of work in the plan. The dependency graph is a DAG, a task WEIGHS its duration in working days (weekends and observed US federal holidays out, the same arithmetic everything else in the app counts with) and a milestone weighs nothing, and the critical path is the maximum-weight path through it, found by the classic longest-path DP. It reads the dates the plan has right now, so it is independent of Auto-schedule and never moves anything. **This is not zero-float CPM** and it deliberately stopped being it in 0.49.0: CPM anchors its backward pass on the plan's latest finish, so it always answers "which chain ends last", and on a real plan that picks a short simple chain running into December over a ten-row interconnected build finishing in November. An isolated row is a path of one and can win if it is genuinely the longest thing there is. **One button, an on/off TOGGLE**, with one constant label and the state carried by its pressed treatment: off, or one longest path inside EVERY section at once, all lit together. 0.49.0 offered a plan-wide reading as a third state of the same button and 0.50.0 dropped it, because on a real plan it lights one section's chain and leaves every other section dark, which is a narrower reading than the per-section answer that contains it. With no label to swap, the button sizes off its own content like every other header action rather than being pinned to the widest of three words. Each lane is answered on its OWN subgraph: the nodes are that section's rows and the edges are the dependencies whose two ends are both in it, so a dependency that crosses sections is not an edge there and can never light up in that mode, and a section with no internal dependencies lights its longest single ROW, which is the same rule rather than an exception to it. **CANCELLED WORK IS NOT A NODE, since 0.53.0:** it can never glow, it carries no weight, and a chain that ran through it is BROKEN at that row, since the node is absent and its predecessors and successors are no longer joined by way of it. A section with a second route round the cancelled row is REROUTED onto it; a section whose only route ran through it falls back to the longest single row left, which may be neither of the two stumps' first halves. The longest-single-row fallback ignores cancelled rows for the same reason: they are not there to be considered. Nothing is deleted from the data, so un-cancelling puts the node, its links and every path through it straight back. Ties are broken deterministically, in this order: more working days, then a greater calendar span (first start to last end), then more rows, then whichever begins earlier in plan order, so exactly one path is ever lit per answer. A GAP adds no weight, because a path's length is the sum of the NODES along it and not the calendar it is spread over, so the gaps 0.53.0's push-only Auto-schedule now preserves cannot promote or demote a chain; the calendar span is reachable only by the second tie-break, where it separates two chains already tied on working days and says the one occupying more of the calendar is the more critical, which is the reading it has always had. The bars, the arrows along the path and any milestone on it glow white and breathe on a 2.6s cycle, in a saturated indigo in light mode and on paper, and the table lens marks the same rows on their left edge. Purely additive: selection outlines, dependency-mode amber and armed red all still read on a glowing row, and hit-testing is untouched. It recomputes live as dates and dependencies change, and the state is a saved preference beside the theme and the milestone style; a browser that stored the old plan-wide mode comes back lit, since that reader had the glow on. A collapsed section hides its own glow with its rows and the rest of the plan keeps glowing. Disabled, with the same message it has always used, when the plan has no dependencies at all: a path is a run of work THROUGH dependencies, and a plan where nothing waits on anything has no runs to be longest - **Click legend** to filter phases, milestones, dependency arrows, or the today line. The legend WRAPS onto as many lines as it needs (0.57.0), so every phase chip on a 20-section plan is visible and clickable; until 0.56.0 it was one line and clipped everything past the pane edge. Below 1150px the chips drop their words and keep the colour dot, as before@@ -395,6 +407,27 @@ It is WHOLE-PLAN by construction, which is why the legend and the filters are hi with it: a total that quietly excluded a filtered lane would be a wrong total presented as a right one. It renders in both themes and prints. +## The setup view++The fourth lens, since 0.58.0, and the home of everything that describes the plan's+STRUCTURE rather than its work. Today that is the sections: one row per entry in+`phases[]`, in order, each with its colour swatch, its name and aside (`lead`), how many+rows it holds and the first and last dates among them (a task's start and end, a+milestone's date; a section with no dated rows says "no dates"). From there a section is+added (the name box at the top; the new one goes on the end and is scrolled to), renamed+and given an aside (Edit), recoloured (the swatch: a brand-safe palette, Theme for the+adaptive ramp, or a `#rrggbb`), moved up or down, or removed. Removing a section with rows+in it asks where they go first; nothing is ever orphaned.++It replaced the **Sections** button that sat in the header through 0.57.1 and opened the+same editor as a modal. The operations are the same functions and write the same things:+every change is one Ctrl+Z (Ctrl+Z on the page repaints the page) and one changelog event+with `via: "sections"` (`phase.create`, `phase.rename`, `phase.lead`, `phase.color`,+`phase.reorder`, `phase.delete`). One row editor is open at a time, and Escape cancels it.+The legend and the chart-only controls are hidden here, as on Analytics. The lens is a+saved preference like the others, so a refresh on Setup comes back to Setup, and+`project.view.mode: "setup"` opens a plan on it. It works in an exported HTML snapshot too.+ ## uid: the one handle worth quoting  Every item carries a `uid`, a zero-padded number assigned once and never changed.@@ -461,7 +494,14 @@ Each event records the action, the item, the before and after dates, the day shi `start-revert`, `auto-schedule`, `undo`).  Actions are `dates`, `plan.shift`, `auto-schedule`, `milestone`, `milestone.rename`,-`note`, `owner`, `status`, `desc`, `dep.add`, `dep.remove` and `reorder`.+`note`, `owner`, `status`, `desc`, `tags`, `phase.tags`, `dep.add`, `dep.remove` and `reorder`.++A tag edit carries the whole list on both sides. A row's is+`{"action":"tags","uid":"00042","id":"etch","name":"Etch the cards","from":{"tags":[]},"to":{"tags":["long-lead"]},"via":"editor"}`+and reads `Drew tagged "Etch the cards" with long-lead (was none)`. A section's is+`{"action":"phase.tags","phase":3,"name":"Tech · Fab","from":{"tags":[]},"to":{"tags":["Tech"]},"via":"setup"}`+and reads `Drew tagged section "Tech · Fab" with Tech (was none)`. An empty `to` reads+`cleared the tags on ...`.  Every event must carry a string `action`, and `events` must be an array; `POST /log` returns 400 otherwise, because the log is append-only and anything accepted is@@ -584,7 +624,7 @@ month abbreviation and carries a four-digit year, so `(EVT)` and `(Year 1-2)` ar ## State file  Edits (date changes, dependency adds/removes, notes, owners, statuses, descriptions,-milestone dates, phase ranges and row order) are saved to a sidecar JSON file next to+milestone dates, phase ranges, tags and row order) are saved to a sidecar JSON file next to your data file. For example, `smt-line.json` saves state to `smt-line-state.json`. This file only contains deltas. Delete it to reset to the original data file values. @@ -592,14 +632,19 @@ The sidecar is at `v: 4` and everything in it is keyed by `uid`:  | Field | Shape | |-------|-------|-| items | `{uid, s, e, date, deps, note, owner, status, desc, prov, name, phase}`, one entry per changed row. Tasks carry `s`/`e`, milestones carry `date` |+| items | `{uid, s, e, date, deps, note, owner, status, desc, prov, name, phase, tags}`, one entry per changed row. Tasks carry `s`/`e`, milestones carry `date` | | order | a list of uids, the plan's row order | | deleted | a list of uids the user removed. Those uids are retired and never reissued | | added | rows the user created, each carrying its own `uid` |-| phases | `{i, start, end}`, keyed by phase index, because a phase is not a row |+| phases | `{i, start, end, tags}`, keyed by phase index, because a phase is not a row. A current client writes the whole section list (`name`, `uid`, `lead`, `color` too) |  An absent field means the sidecar has nothing to say and the data file's value-stands; an empty one means the user cleared it.+stands; an empty one means the user cleared it. `tags` follows the same rule: no key+keeps the data file's tags (for a section, found by its `uid` and then its name, so a+reorder written by a client that predates tags loses nothing), and `"tags": []` means+the user removed them all. The server normalises every tag list it reads, here, in the+data file and in every import, and a `tags` that is not a list is dropped rather than+refused.  `v: 1` (a bare task array), `v: 2` and `v: 3` are still read, forever. They keyed rows positionally, by `#row<n>`, which is the fragility uid exists to end, so the@@ -616,7 +661,7 @@ is why `project.nextUid` can be higher than any uid you can see.  ## Tests -`node test/ui_test.mjs` (from the package directory) is the browser suite, added in 0.57.0 and built to the app-testing skill. It spawns `server.js --no-auth` on a scratch port (8960 up) against a SEEDED synthetic plan in a temp dir (20 sections, 123 rows, 21-Sep-2026 to 14-Jan-2027), pins the page's clock to 29-Sep-2026, and drives Chrome from `~/.cache/puppeteer` through the vendored `test/node_modules/puppeteer-core` (`cd test && npm install` once; it does not ship). It asserts effects: the header readouts against an oracle computed from the plan the SERVER saved after each edit (drag, undo, redo, auto-schedule, table edit, delete, paste, create, start shift), the start date at every grain, text size across a refresh in the live app, in a second tab and in an exported snapshot, collapse all in both lenses and across a refresh, and the wrapped legend with the sticky axis, pinned section header and table header measured at 100% and 150% text and across a resize. A coverage manifest fails on any control nobody declared, on any declared control that never renders, and clicks every safe control. 393px, 1440px and 1920px are rendered with no sideways scroll allowed, and a header sweep (every 40px from 360 to 2000 plus 15 named widths; `SWEEP_STEP=20` for denser) asserts both readouts are whole and every header control is on screen, with and without an appName, at 100% and 150% text. Ports are checked free before use and the server answering /health must be the one the suite spawned. `--keep` leaves screenshots in `test/out/`; `--plan <file>` also runs the header and legend checks against a real plan, COPIED to a temp dir first. It exits non-zero on any failure.+`node test/ui_test.mjs` (from the package directory) is the browser suite, added in 0.57.0 and built to the app-testing skill. It spawns `server.js --no-auth` on a scratch port (8960 up) against a SEEDED synthetic plan in a temp dir (20 sections, 123 rows, 21-Sep-2026 to 14-Jan-2027), pins the page's clock to 29-Sep-2026, and drives Chrome from `~/.cache/puppeteer` through the vendored `test/node_modules/puppeteer-core` (`cd test && npm install` once; it does not ship). It asserts effects: the header readouts against an oracle computed from the plan the SERVER saved after each edit (drag, undo, redo, auto-schedule, table edit, delete, paste, create, start shift), the start date at every grain, text size across a refresh in the live app, in a second tab and in an exported snapshot, collapse all in both lenses and across a refresh, and the wrapped legend with the sticky axis, pinned section header and table header measured at 100% and 150% text and across a resize. A coverage manifest fails on any control nobody declared, on any declared control that never renders, and clicks every safe control. 393px, 1440px and 1920px are rendered with no sideways scroll allowed, and a header sweep (every 40px from 360 to 2000 plus 15 named widths; `SWEEP_STEP=20` for denser) asserts both readouts are whole and every header control is on screen, with and without an appName, at 100% and 150% text. Since 0.58.0 the sweep covers all four lenses, and the viewport matrix renders Analytics and Setup too (Setup with 20 sections, plain and with a rename and a remove form open). The `sections` flow drives the Setup page on a plan with rows in every section and no phase uids: after every add, reorder and remove, after each one's undo and redo, and after a reload, it compares the whole section list and every row's section with `/export/json` and checks the page repainted to match; it also covers the one-undo Save, duplicate names refused, Enter pressed five times making one section, an open Remove form surviving Ctrl+Z, the row chords doing nothing on Setup, the chart axis after adding a section, and rename, reorder and remove in an exported snapshot. `node test/server_test.mjs` is the server's own suite (ports 8940 up). Ports are checked free before use and the server answering /health must be the one the suite spawned. `--keep` leaves screenshots in `test/out/`; `--plan <file>` also runs the header and legend checks against a real plan, COPIED to a temp dir first. It exits non-zero on any failure.  ## Example 
docs/BUILD-NOTES.md+94
@@ -3,6 +3,100 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-09-30 (0.58.0, text size on a sandboxed snapshot)++Drew reported the text size still resetting on refresh, on the snapshot published at+wiki.adom.inc/drew2/instapcb-launch/render/gantt/index.html, and that snapshot was+already 0.57.1. Reproduced in Chrome logged in as him: the wiki serves an uploaded HTML+file under `Content-Security-Policy: sandbox allow-scripts allow-downloads allow-popups`,+with no allow-same-origin. That is the right header for user content on a site with+sessions, and it gives the page an opaque origin: A+ wrote `{"fs":1.1}` to storage and+read 110%, and the reload came back with empty storage and 100%. Every reload is a new+origin.++So a snapshot now also writes its reader prefs into the URL fragment (`#fs=1.2`, via+replaceState, so nothing navigates and no history entry is made), and reads the fragment+back where storage has nothing. A reload keeps the fragment, and so do a bookmark and a+shared link. Storage still wins where it works, so a link carrying somebody else's size+does not replace a reader's own. The suite serves the exported snapshot from a throwaway+server under the same header and checks the size survives a reload there, and that the+sandbox really does deny or forget storage, so the check cannot pass by accident.++## Build notes, 2026-09-29 (0.58.0, a Setup tab owns the sections)++Drew asked for the sections to be managed on their own page "instead of the button at+top of Gantt/Table page". So the header's **Sections** button and its modal are gone, and+a fourth lens, **Setup**, sits after Analytics in the lens switch. It is the first of+three checkpoints (0.59.0 puts tags on sections, 0.60.0 on rows), and the page is built+to take them: the sections are one card in a stack, and a Tags card goes under it.++* **The same editor, a different frame.** Add, rename and set the aside, recolour,+  reorder and remove are the same mutation functions they were, with the same undo+  entries and the same changelog events (`via: "sections"`). The row markup and class+  names are the modal's too, so the flows that proved them could be moved rather than+  rewritten. What a page has room for, and the modal did not: each section's row count+  and the first and last dates its rows span, read the same way the Analytics+  per-section card reads them.+* **A page is never open or closed.** The modal's own Escape handler and its Done button+  went with it. Escape on the page belongs to the one row editor that is open: from its+  text fields (they are in `ESC_OWNED`) and from its buttons or its destination list (a+  rung in the app-level Escape stack). Opening a second row editor closes the first.+* **Ctrl+Z repaints the page.** The modal never listened for it, so an undo pressed+  with the modal open left a stale list on screen. `refreshSections()` is where every+  phase change and every phase undo already ends, so the page re-renders from there,+  and `scheduleSave` re-renders it the way it re-renders Analytics, for undoing a row+  edit that changes a count. An open row editor is rebuilt with the list and keeps+  what was typed (see the review fixes below).+* **The header lost a button and the switch gained one**, and the measured fit absorbed+  both: the sweep (54 widths, 100% and 150% text) passes in all four lenses. Setup hides+  what Analytics hides.+* **`project.view.mode` now takes any lens.** It only ever read `gantt` and `table`, so a+  plan authored to open on its totals opened on the chart. It reads the lens list now,+  which is how `mode: "setup"` works at all.+* **A snapshot has the tab** and adds a section with no server, through the snapshot's+  own id allocator, as the New panel always has there.++**The review found three data bugs, a stale editor and a handful of smaller things.**+All reproduced before they were fixed, and the suite now fails on each of them.++* **Moving a section past a newly added one moved its rows on reload** (and in the saved+  plan and the snapshot). The sidecar's row overrides were applied BEFORE its full+  section list replaced the data file's, so each row's saved section was checked against+  the data file's shorter list; an index past its end failed, and the row kept its old+  index, which by then was another section. The page and the server's merge had the same+  line, both since 0.57.1. Both now check against the length of the list the sidecar+  brings.+* **A new section put the chart axis at 1970 after a reload.** A section made in Setup has+  no month range of its own, and `scanRange` read the blank as `-01`, which sorts before+  every date. A section with no range now adds nothing to the axis (its span is where its+  rows are, which the rows already count), and the start-date shift skips it. The+  server's readers already skipped such a section.+* **Undoing a reorder merged two sections that shared a name.** The undo found sections+  by uid or else name, and the real plan's sections have no uid. It now finds them by a+  client-only key (`phaseKey`) minted per section object and carried through every undo+  copy, never written to disk. And names must now differ: add and rename refuse a name+  another section has, trimmed and in any case, and say so.+* **An open row editor acted on the wrong section after Ctrl+Z.** The page skipped its+  repaint while an editor was open, so the editor kept the index it was opened with and+  the undo moved another section into that slot. Every editor now resolves its section+  by key at the moment it acts, and a repaint while one is open rebuilds the list and+  reopens the editor on its section's new row with what was typed, closing it only if+  the section is gone. A remove destination is keyed the same way.+* Smaller: Enter pressed repeatedly while an id was being fetched made duplicates (one+  add at a time now); Alt+Up/Down and Ctrl+C/Ctrl+V acted on hidden rows from Analytics+  and Setup (they answer only in Gantt and Table now); a Save that renames and sets the+  aside was two undo steps (one now, through `withOneUndo`); a bad colour got a red+  border and no words (it says what is wrong, and `#ABC` or `abc` is stored as+  `#aabbcc`). Typed text in a row editor now survives a lens switch and a repaint;+  opening another row's editor still drops it, as clicking into another table cell does.++The suite: the `sections` flow drives the page and asserts the same effects against+the saved plan, plus the counts and dates, Escape from a field and from a button, a+typed hex, Ctrl+Z and redo repainting the list, cancel on a remove, the six changelog+actions, a refresh coming back to Setup, the snapshot and `mode: "setup"`. The coverage+gate opens the page and each of its three row editors as states, and the controls inside+those editors, which the gate never opened while they lived in a modal, are declared.+ ## Build notes, 2026-09-29 (0.57.1, second review: a failed save is retried, a wrapped header keeps its words)  Three narrow reviewers ran in parallel against 0.57.0 after it was published: the header
docs/plan-0.58-setup-and-tags.mdadded+148
@@ -0,0 +1,148 @@+# adom-gantt 0.58 to 0.60: a Setup tab, and tags on sections and rows++Date: 2026-09-29. Requested by Drew. A guide, not a contract: if an assumption+below is wrong, do the smarter thing and record it under Build notes.++Built in three checkpoints. Each one is shippable on its own, is published to+the wiki when its tests are green, and is reviewed while the next one is being+built.++## Working copy and tools++- Source: /home/adom/project/adom-gantt (files-only clone, 0.57.1). The app is+  `gantt.html`, served by `server.js`.+- Pristine 0.57.1 for diffing: /home/adom/project/adom-gantt-0.57.1-ref/+- Real plan: /home/adom/project/instapcb-launch/gantt/instapcb-launch.json+  (20 sections named like "Launch · Program and gates", "Tech · Laser+  workcells", "Process · Fab", "Ops · factoryOS"). Never serve that path+  directly, the server writes sidecars beside its data file: copy it first.+- Run: `node server.js --data <copy>.json --port <8950-8959> --no-auth`.+  Port 8901 is a live preview, leave it alone.+- Tests: `node test/ui_test.mjs` is the browser suite (12 minutes for a full+  run). `--only <group>` runs a subset; group names are matched by+  `wantAny(...)` in the file (editor, live, sections, coverage, header sweep,+  viewport, text size, ...). Read ~/.claude/skills/app-testing/SKILL.md.+  The suite has a control-coverage manifest: every new control must be+  declared, and every `safe` control is clicked.++## Checkpoint 1 (0.58.0): the Setup tab owns section management++1. A fourth tab in the lens switch, after Analytics, named **Setup**. It is a+   page like Table and Analytics are, not a modal. The legend and the chart+   controls that mean nothing there are hidden, the way Analytics hides them.+2. Everything the header's **Sections** button does today moves onto that+   page: add a section, rename it and set its aside, recolour, reorder,+   remove (with its rows re-homed the way it works today), all undoable and+   all in the changelog exactly as now. On a page there is room to show more+   than the modal did: each section's row count and date range are worth+   showing.+3. The Sections button leaves the header. Nothing else in the header moves.+   The measured header fit (`fitHeader`) must still fit at every width in all+   FOUR lenses.+4. `prefs.view` and `project.view.mode` accept the new lens. A refresh on+   Setup comes back to Setup.+5. An exported HTML snapshot has the tab too and it works there with no+   server, like every other edit does in a snapshot.+6. Tests: the `sections` flows drive the page instead of the modal; the+   coverage manifest declares the new controls; the header sweep covers the+   new lens. The suite stays green.++## Checkpoint 2 (0.59.0): tags on sections++1. Data: `phases[].tags`, an array of short strings. Trimmed, de-duplicated+   without regard to case (first spelling wins), a sensible cap on length and+   count. Absent means none. Tags carry no colour of their own: colour on this+   chart means the section, and only that.+2. Persistence end to end in `server.js`: the sidecar, `/export/json`,+   `/import/json`, the HTML snapshot, and the changelog (a tag edit is an+   event with a readable line in `?format=md`). Old data files and old+   sidecars load unchanged.+3. Setup page: edit a section's tags in place (type, Enter, remove with a+   click; suggest tags already in the plan). A **Tags** block lists every tag+   in the plan with how many sections (later, rows) carry it, and can rename+   a tag everywhere or remove it everywhere. Undo and redo cover all of it,+   one gesture to one Ctrl+Z.+4. One click to make tags from name prefixes: for sections named+   "Launch · Program and gates" it offers the tag "Launch". It adds tags and+   never renames a section. Only offered when the plan has such names.+5. Seeing them: the section header row in the chart shows its tags as small+   quiet chips, and the Table's Phase column can show them on hover or+   inline, whichever reads better.+6. Filtering: the legend row gains tag chips. Choosing one or more tags shows+   only the sections carrying any of them, in the chart and the table alike.+   It composes with the per-section chips and with Collapse all. It is view+   state, per person, like the section filter is. With 20 sections and 4 tags+   the legend must stay tidy: the tags are what make a 20-chip legend+   manageable, so consider letting a tag chip stand in front of its sections.+7. Tests for every item above, asserting effects against `/export/json`.++## Checkpoint 3 (0.60.0): tags on tasks and milestones (optional per row)++1. Data: `items[].tags`, same rules. A row's EFFECTIVE tags are its own plus+   its section's; the tag filter uses effective tags, so a task tagged+   "long-lead" in an untagged section is found by that tag.+2. Editing: a Tags field in the full item editor and in the New panel, a Tags+   column in the Table (editable in place, sortable, filterable), and the+   batch panel can add or remove a tag across the selection.+3. `/export/csv` and `/import/csv` carry a Tags column. Copy and paste carry+   tags.+4. Analytics: a per-tag card (items, hours, share), counting effective tags.+5. The Tags block on Setup counts rows as well as sections.+6. Tests for every item above.++## Housekeeping, every checkpoint++- Bump the version in `package.json` and `page.json`.+- Update `SKILL.md` and `README.md` (the schema tables, the features list,+  the lens descriptions). No em dashes in prose. Match the surrounding voice.+- Add an entry at the TOP of `docs/BUILD-NOTES.md` (it is newest first and+  about 400KB: read only its head).+- Do not publish, do not touch /home/adom/project/adom_modules or+  ~/.claude/skills, leave no processes running. The orchestrator publishes.++## Build notes++## Build notes - 2026-09-29, checkpoint 1++Built as planned, with these changes to the plan's assumptions:++- **`project.view.mode` did not accept Analytics either.** `viewDefaults()` read only+  `gantt` and `table`, so "accept the new lens" meant fixing the list, not adding one+  value. It now reads a single `VIEW_LIST` (the same list `VIEWS` is), so any lens,+  including `setup` and `analytics`, can be authored as the opening lens.+- **The modal's class names and row markup were kept** (`.sec-row`, `.sec-btn[data-a]`,+  `.sec-sw`, `#secNew`, `#secAdd`). The operations were already correct and tested; moving+  the frame rather than rewriting the rows kept the flows transferable and gives+  checkpoint 2 one obvious place to add a tag editor per row (a new element in+  `renderSections()`), with the Tags block as a second `.an-card` in `.su-stack`.+- **One row editor at a time**, which the modal did not enforce. On a page with 20 rows,+  two half-open forms is a real state; opening one closes the other. The swatch toggles.+- **Undo repaints the list.** The modal never did (Ctrl+Z with it open left a stale+  list). The page re-renders from `refreshSections()` and `scheduleSave`, skipping while+  a row editor is open. Checkpoint 2's tag edits should end in `refreshSections()` too.+- **The date range is read from the rows**, not from `phases[].start/end`, matching the+  Analytics per-section card and what the chart draws.+- **Coverage now opens the three row editors** as states and declares their controls+  (`.sec-nm`, `.sec-ld`, `.sec-ok`, `.sec-chip`, `.sec-hex`, `.sec-dest`, `.sec-go`,+  `.sec-no`); inside the modal the gate never reached them. `#secDone` is gone.+- Observed while building: another process was editing `server.js`, `README.md`,+  `SKILL.md` and `test/server_test.mjs` in this same working copy (server-side tags).+  Checkpoint 1 did not touch `server.js`; its README and SKILL edits were made as+  narrow in-place replacements so as not to overwrite that work.++## Build notes - 2026-09-30, checkpoint 1 review fixes++- Section order and row assignment after a reload (V-A) was wrong in both the page and+  the server's merge, since 0.57.1: the row overrides were checked against the data+  file's section list before the sidecar's longer list replaced it. Fixed in both+  (`server.js` with the coordinator's go-ahead), with a server_test case.+- An empty section range (V-B) now means "no range of its own" everywhere the page reads+  one: the axis scan and the start-date shift. The server already skipped it.+- Sections now have a client-only identity, `phaseKey` (`_k`), used by the reorder undo+  and by every Setup row editor instead of a name or an index. Checkpoint 2's tag editor+  per section should resolve its section the same way (`phaseIndexOfKey`) and should+  end in `refreshSections()` so an open editor is kept across repaints.+- Section names must be unique (trimmed, case-insensitive). The checkpoint 2 tag rename+  should expect that a section name identifies one section.+- One gesture, one undo, via `withOneUndo`: checkpoint 2's "rename a tag everywhere" is+  the obvious next user of it.
gantt.html+390−129
@@ -1066,7 +1066,13 @@ body.row-dragging .data-table tbody tr:hover { background: none; } :root[data-view="analytics"] header .start-ctl, :root[data-view="analytics"] #btnMsStyle, :root[data-view="analytics"] #btnCrit,-:root[data-view="analytics"] #btnAuto { display: none; }+:root[data-view="analytics"] #btnAuto,+:root[data-view="setup"] .chart-wrap,+:root[data-view="setup"] .legend,+:root[data-view="setup"] header .start-ctl,+:root[data-view="setup"] #btnMsStyle,+:root[data-view="setup"] #btnCrit,+:root[data-view="setup"] #btnAuto { display: none; } .an-scroll { flex: 1; min-height: 0; overflow: auto; scrollbar-gutter: stable; padding: 16px 18px 40px; } .an-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; align-items: start; max-width: 1600px; } .an-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 13px 15px 15px; min-width: 0; }@@ -1119,7 +1125,8 @@ table.an-table tr.total td { border-top: 1px solid var(--border); border-bottom:    the same reason the start control is: a reader can tell at a glance which buttons    change the plan and which change the view. */ .plan-ctl { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }-:root[data-view="analytics"] .plan-ctl { display: none; }+:root[data-view="analytics"] .plan-ctl,+:root[data-view="setup"] .plan-ctl { display: none; } .plan-ctl .act-btn[disabled] { opacity: 0.4; cursor: default; } .plan-ctl .act-btn[disabled]:hover { background: none; border-color: var(--border); color: var(--text2); } .plan-ctl .act-btn.on { background: var(--elevated); color: var(--crit); border-color: var(--crit); }@@ -1173,32 +1180,78 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .label-row.row-collapsed, .bar-row.row-collapsed, .ms-line.row-collapsed { display: none !important; } .label-row.group { position: relative; padding-right: 4px; } .label-row.group .lane-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }-.sec-back { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 99990; display: flex; align-items: center; justify-content: center; }-.sec-modal { width: min(620px, 94vw); max-height: 84vh; display: flex; flex-direction: column; background: var(--overlay); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 18px 48px rgba(0,0,0,0.5); }-.sec-head { display: flex; align-items: baseline; gap: 10px; padding: 14px 16px 10px; border-bottom: 1px solid var(--border-muted); }-.sec-head h2 { margin: 0; font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 600; letter-spacing: -0.01em; }-.sec-head .sec-hint { font-size: 11px; color: var(--text2); }-.sec-list { overflow-y: auto; padding: 8px 10px; }-.sec-row { display: flex; align-items: center; gap: 8px; padding: 7px 6px; border-radius: 7px; border: 1px solid transparent; }+/* ── Setup: the plan's own furniture, on a page of its own (0.58.0) ──+   Until 0.57.1 the sections were managed from a modal behind a header button. Drew+   asked for them to have their own page instead, and a page is the better home for+   more than tidiness: it has the room to show what a modal could not (each section's+   row count and the dates its rows span), and it is where the section tags go next,+   so everything that describes the plan's STRUCTURE rather than its work lives in+   one place. It is the fourth lens, built the way Analytics is: a scroll of cards in+   the same tokens, the legend and the chart-only controls gone, because there is no+   chart here for them to act on.++   The row markup and class names are the modal's, kept on purpose: .sec-row, the+   data-a actions and the inline editors are the same code paths they always were,+   now in a card instead of a dialog. */+.setup-view { display: none; flex: 1; min-height: 0; flex-direction: column; background: var(--bg); }+:root[data-view="setup"] .setup-view { display: flex; }+.su-scroll { flex: 1; min-height: 0; overflow: auto; scrollbar-gutter: stable; padding: 16px 18px 40px; }+.su-stack { display: flex; flex-direction: column; gap: 14px; max-width: 980px; }+.su-card .su-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }+.su-card .su-head h3 { margin: 0 0 3px; }+.su-card .su-n { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2); }+.sec-add { display: flex; gap: 8px; align-items: center; margin: 0 0 10px; }+.sec-add input[type="text"] { flex: 1; min-width: 0; max-width: 360px; }+.setup-view input[type="text"], .setup-view select { background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 5px 8px; font-family: inherit; font-size: 12px; }+.setup-view input[type="text"]:focus, .setup-view select:focus { outline: none; border-color: var(--accent); }+/* A header row over the list, in the analytics table's type, so the two numbers on+   each row say what they are once rather than on every row. */+.sec-cols { display: flex; align-items: center; gap: 10px; padding: 0 8px 5px; border-bottom: 1px solid var(--border); font-family: 'Familjen Grotesk', sans-serif; font-size: 10px; font-weight: 600; color: var(--text2); }+.sec-cols .c-name { padding-left: 25px; }+.sec-cols .sec-count, .sec-cols .sec-dates { font-family: inherit; font-size: inherit; color: inherit; }+.sec-cols .c-name { flex: 1 1 200px; }+.sec-list { padding: 4px 0 0; }+.sec-row { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px; padding: 6px 8px; border-radius: 7px; border: 1px solid transparent; }+.sec-row + .sec-row { border-top-color: rgba(48,54,61,0.15); border-top-left-radius: 0; border-top-right-radius: 0; } .sec-row:hover { background: var(--row-hover); }-.sec-row.sec-editing { border-color: var(--border); background: var(--surface); }+.sec-row.sec-editing { border-color: var(--border); background: var(--elevated); border-radius: 7px; }+.sec-row.sec-flash { animation: secFlash 1.4s ease-out; }+@keyframes secFlash { from { background: rgba(0,184,177,0.18); } to { background: transparent; } } .sec-sw { width: 15px; height: 15px; border-radius: 4px; border: 1px solid var(--border); flex-shrink: 0; cursor: pointer; padding: 0; }-.sec-name { flex: 1; min-width: 0; font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }-.sec-name .sec-lead { color: var(--text3); font-size: 0.86em; margin-left: 7px; }-.sec-count { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); flex-shrink: 0; min-width: 44px; text-align: right; }+.sec-name { flex: 1 1 200px; min-width: 0; font-size: 12.5px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.sec-name .sec-lead { color: var(--text2); font-size: 0.86em; margin-left: 7px; }+.sec-count, .sec-dates { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2); flex-shrink: 0; text-align: right; white-space: nowrap; }+.sec-count { width: 58px; }+.sec-dates { width: 196px; }+.sec-dates.none { color: var(--text3); } .sec-acts { display: flex; gap: 2px; flex-shrink: 0; }-.sec-btn { background: none; border: 1px solid transparent; color: var(--text2); cursor: pointer; border-radius: 5px; padding: 3px 6px; font-size: 10px; font-family: inherit; }-.sec-btn:hover { background: var(--elevated); color: var(--text); border-color: var(--border-muted); }+/* The column header's invisible copy of the four actions, so "Dates" sits over the+   dates whatever width the font gives the buttons. Not buttons, so nothing can focus+   or click them. */+.sec-ghost { border: 1px solid transparent; padding: 3px 7px; font-size: 11px; }+.sec-btn { background: none; border: 1px solid transparent; color: var(--text2); cursor: pointer; border-radius: 5px; padding: 3px 7px; font-size: 11px; font-family: inherit; }+.sec-btn:hover { background: var(--surface); color: var(--text); border-color: var(--border-muted); } .sec-btn[disabled] { opacity: 0.35; cursor: default; }+.sec-btn[disabled]:hover { background: none; border-color: transparent; color: var(--text2); } .sec-btn.danger:hover { color: #f85149; }-.sec-foot { padding: 10px 16px 14px; border-top: 1px solid var(--border-muted); display: flex; gap: 8px; align-items: center; }-.sec-foot input[type="text"] { flex: 1; min-width: 0; }-.sec-modal input[type="text"] { background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 5px 8px; font-family: inherit; font-size: 12px; }-.sec-modal select { background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 5px 8px; font-family: inherit; font-size: 12px; }-.sec-pal { display: flex; gap: 5px; flex-wrap: wrap; padding: 6px 2px 2px; }+.sec-pal { display: flex; gap: 5px; flex-wrap: wrap; align-items: center; padding: 6px 2px 2px 25px; } .sec-chip { width: 18px; height: 18px; border-radius: 5px; border: 2px solid transparent; cursor: pointer; padding: 0; } .sec-chip.on { border-color: var(--text); }-.sec-err { color: #f85149; font-size: 11px; padding: 0 16px 8px; }+.sec-msg { flex: 1 1 100%; color: #f85149; font-size: 11px; padding-top: 2px; }+.sec-err { color: #f85149; font-size: 11px; margin: -4px 0 8px; }+/* A phone: the two numbers drop under the name, indented to line up with it, and the+   row actions follow them on the same line, so a 20-section plan is still one short+   row per section rather than a column of wrapped buttons. The header row goes,+   since the numbers are no longer in columns for it to label. */+@media (max-width: 640px) {+  .su-scroll { padding: 12px 10px 32px; }+  .sec-cols { display: none; }+  .sec-name { flex-basis: calc(100% - 30px); }+  .sec-count { width: auto; margin-left: 25px; text-align: left; }+  .sec-dates { width: auto; text-align: left; }+  .sec-acts { margin-left: auto; }+  .sec-add input[type="text"] { max-width: none; }+} .lane-lead { color: var(--text3); font-weight: 400; font-style: normal; margin-left: 7px; font-size: 0.86em; } .lane-toggle { display: flex; align-items: center; gap: 5px; background: none; border: none; color: var(--text2); cursor: pointer; padding: 2px 4px; border-radius: 4px; font-family: 'JetBrains Mono', monospace; font-size: calc(9px * var(--fs)); flex-shrink: 0; } .lane-toggle:hover { background: var(--row-hover); color: var(--text); }@@ -1430,6 +1483,23 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va   <div class="analytics-view" id="analyticsView">     <div class="an-scroll" id="anScroll"></div>   </div>+  <!-- The Setup lens. Static markup like the other three, so an exported snapshot has+       it too; the section list inside it is rebuilt from phases[] by renderSections. -->+  <div class="setup-view" id="setupView">+    <div class="su-scroll" id="suScroll"><div class="su-stack">+      <div class="an-card su-card" id="suSections">+        <div class="su-head"><h3>Sections</h3><span class="su-n" id="secTotal"></span></div>+        <p class="an-sub">The lanes of this plan, in the order the chart and the table show them. Every change here is one Ctrl+Z.</p>+        <div class="sec-add">+          <input type="text" id="secNew" placeholder="New section name" maxlength="60" aria-label="New section name">+          <button class="de-btn primary" id="secAdd">Add section</button>+        </div>+        <div class="sec-err" style="display:none"></div>+        <div class="sec-cols" aria-hidden="true"><span class="c-name">Section</span><span class="sec-count">Rows</span><span class="sec-dates">Dates</span><span class="sec-acts" style="visibility:hidden"><span class="sec-ghost">&uarr;</span><span class="sec-ghost">&darr;</span><span class="sec-ghost">Edit</span><span class="sec-ghost">Remove</span></span></div>+        <div class="sec-list" role="list"></div>+      </div>+    </div></div>+  </div> </div> <datalist id="ownerList"></datalist> <div class="tip" id="tip"></div>@@ -1627,6 +1697,7 @@ const APP_VERSION = /^[0-9]/.test(RAW_VERSION) ? RAW_VERSION : ''; // project.view is the author's intended default view. It applies everywhere the chart // is served fresh: the HTML export, the server render, and any browser with no saved // preference. In the live app a preference the user has actually set still wins.+const VIEW_LIST = ['gantt','table','analytics','setup']; function viewDefaults(){   const v = (project && project.view) || {};   const d = {};@@ -1639,8 +1710,10 @@ function viewDefaults(){   if (v.theme === 'light' || v.theme === 'dark') d.theme = v.theme;   if (v.labelWidth) d.labelW = Math.max(120, Math.min(760, +v.labelWidth || 0)) || undefined;   // Which lens the plan opens in. An owner-and-deadline plan can be authored to-  // open as the table, the same way a chart can be authored to open in days.-  if (v.mode === 'table' || v.mode === 'gantt') d.view = v.mode;+  // open as the table, the same way a chart can be authored to open in days. Any+  // lens, since 0.58.0: through 0.57.1 this only knew the first two, so a plan+  // authored to open on its totals silently opened on the chart instead.+  if (VIEW_LIST.includes(v.mode)) d.view = v.mode;   return d; } // Prefs that describe the READER rather than the plan. A snapshot still opens at its@@ -1665,12 +1738,48 @@ function storedPrefs(){   // would take the merge below silently (keys set on an array vanish from its JSON).   try { const o = JSON.parse(localStorage.getItem(PREF_KEY)); return (o && typeof o === 'object' && !Array.isArray(o)) ? o : {}; } catch { return {}; } }+// A snapshot on a page that SANDBOXES it (0.58.0). The wiki serves an uploaded HTML+// file under `Content-Security-Policy: sandbox` with no allow-same-origin, which is the+// right call for user content on a site with sessions: the page gets an OPAQUE origin.+// Storage works for as long as the page lives, and every reload is a new opaque origin+// with new, empty storage, so a text size set there was gone on the next refresh. That is+// exactly what Drew saw on wiki.adom.inc, on a 0.57.1 snapshot, after the storage fix.+// So a snapshot keeps its reader prefs in the URL FRAGMENT as well (`#fs=1.2`, written+// with replaceState so nothing navigates and no history entry is made). A reload keeps+// the fragment, and so do a bookmark and a shared link. Storage still wins where it+// works: a reader with a size of their own keeps it when somebody sends them a link+// carrying the sender's. The fragment is the answer only where storage has none.+function fragmentPrefs(){+  const out = {};+  if (!SNAPSHOT) return out;+  try {+    const h = String(location.hash || '').replace(/^#/, '');+    h.split('&').forEach(kv => {+      const i = kv.indexOf('='); if (i < 0) return;+      const k = decodeURIComponent(kv.slice(0, i)), v = decodeURIComponent(kv.slice(i + 1));+      if (READER_PREFS.indexOf(k) < 0) return;+      if (k === 'fs') { const f = parseFloat(v); if (isFinite(f)) out.fs = f; }+    });+  } catch {}+  return out;+}+function writeFragmentPrefs(){+  if (!SNAPSHOT) return;+  try {+    const parts = [];+    READER_PREFS.forEach(k => { if (prefs[k] !== undefined) parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(String(prefs[k]))); });+    const hash = parts.length ? '#' + parts.join('&') : '';+    if ((location.hash || '') === hash) return;+    if (history && history.replaceState) history.replaceState(null, '', location.pathname + location.search + hash);+    else location.hash = hash;+  } catch {}+} function savedPrefs(){   const all = storedPrefs();   if (!SNAPSHOT) return all;   const out = {};   READER_PREFS.forEach(k => { if (all[k] !== undefined) out[k] = all[k]; });-  return out;+  return Object.assign(fragmentPrefs(), out); } // Seven steps, 85% to 150%. Discrete so the readout is a round number and the // step buttons have somewhere definite to land.@@ -1764,6 +1873,7 @@ function savePrefs(){       all[k] = prefs[k]; next[k] = j;     });     if (!dirty) return;+    writeFragmentPrefs();     localStorage.setItem(PREF_KEY, JSON.stringify(all));     Object.assign(prefBase, next);   } catch {}@@ -1905,11 +2015,20 @@ function isoMonth(d){ return `${d.getFullYear()}-${String(d.getMonth()+1).padSta // defensive anyway: a throw in here used to blank the entire chart. function parseISO(ds){ const[y,m,d]=String(ds==null?'':ds).split('-').map(Number); return new Date(y||1970,(m||1)-1,d||1); } +const PHASE_MONTH_RE=/^\d{4}-\d{2}/;+function phaseHasRange(p){ return !!p&&PHASE_MONTH_RE.test(String(p.start||''))&&PHASE_MONTH_RE.test(String(p.end||'')); } function scanRange(){   let minD='9999-12-31', maxD='0000-01-01';   items.forEach(r=>{ if(r.start&&r.start<minD)minD=r.start; if(r.end&&r.end>maxD)maxD=r.end;                      if(r.date&&r.date<minD)minD=r.date; if(r.date&&r.date>maxD)maxD=r.date; });-  phases.forEach(p=>{ const ps=p.start+'-01'; const[ey,em]=p.end.split('-').map(Number);+  // A section's own month range widens the axis only when it HAS one. A section made in+  // Setup is stored with start and end blank, and '' + '-01' sorts before every real+  // date, so until 0.58.0 one new section put the axis at 1970 after a reload and every+  // bar off screen. Blank or malformed means "no range of its own": what the section+  // spans is where its rows are, which the rows already count above. The server's+  // readers (SVG, CSV) already skip such a phase the same way.+  phases.forEach(p=>{ if(!phaseHasRange(p))return;+                      const ps=p.start+'-01'; const[ey,em]=p.end.split('-').map(Number);                       const pe=`${ey}-${String(em).padStart(2,'0')}-28`;                       if(ps<minD)minD=ps; if(pe>maxD)maxD=pe; });   return {minD,maxD};@@ -2188,13 +2307,17 @@ $('hdr').innerHTML = `        to the brand block, which never changes width. It used to live inside .actions,        which is right-aligned: every chart-only button that hid in table view made        that group narrower and shoved the toggle sideways between clicks. -->-  <div class="view-toggle" id="viewToggle" role="tablist" title="Three lenses over the same plan: the chart, the owner and deadline table, or the totals">+  <div class="view-toggle" id="viewToggle" role="tablist" title="Four lenses over the same plan: the chart, the owner and deadline table, the totals, or the plan's setup">     <button class="vt-btn" data-view="gantt" role="tab">       <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="3" y="5" width="13" height="3" rx="1.5"/><rect x="7" y="10.5" width="14" height="3" rx="1.5"/><rect x="5" y="16" width="10" height="3" rx="1.5"/></svg>Gantt</button>     <button class="vt-btn" data-view="table" role="tab">       <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9.5h18M3 15h18M10 9.5V20"/></svg>Table</button>     <button class="vt-btn" data-view="analytics" role="tab">       <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>Analytics</button>+    <!-- 0.58.0: the fourth lens. The plan's structure, which the header's Sections+         button used to open in a modal. The icon is the one that button carried. -->+    <button class="vt-btn" data-view="setup" role="tab">+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h13"/></svg>Setup</button>   </div>   <div class="vsep"></div>   <!-- 0.57.0: the plan is named ONCE, in the brand block. Until 0.56.0 this group@@ -2234,7 +2357,6 @@ $('hdr').innerHTML = `     <button class="act-btn" id="btnMsStyle" title="Milestone style, cycled: dashed lines with tags, diamonds on their own row, or both at once">       <span id="msStyleIcon"></span><span id="msStyleLabel"></span></button>     <button class="act-btn" id="btnTheme" title="Toggle light and dark"><span id="themeLabel"></span></button>-    <button class="act-btn" id="btnSections" title="Sections: add, rename, recolour, reorder or remove the plan's lanes"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h13"/></svg><span class="btn-label">Sections</span></button>     <button class="act-btn" id="btnImport" title="Import a plan: JSON, CSV, MS Project XML or Jira CSV"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg><span class="btn-label">Import</span></button>     <button class="act-btn" id="btnExport" title="Export this plan: JSON, CSV, SVG or a self-contained HTML snapshot"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21V9m0 0l4 4m-4-4l-4 4M4 7V5a2 2 0 012-2h12a2 2 0 012 2v2"/></svg><span class="btn-label">Export</span></button>     <button class="act-btn" id="btnHelp" style="font-family:'Familjen Grotesk',sans-serif;font-size:14px;font-weight:600;padding:4px 9px">?</button>@@ -2437,7 +2559,11 @@ const undoStack = []; // rather than at each of the nine places that record an edit, because the tenth // one added later would have forgotten. const redoStack = [];+// Declared here, beside pushUndo, rather than beside the functions that set them, so+// they exist before anything can push. See withOneUndo and phaseKey.+let undoGroup=null, phaseKeySeq=0; function pushUndo(sn){+  if(undoGroup){ undoGroup.push(sn); return; }   // collected by withOneUndo   undoStack.push(sn);   if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);   redoStack.length=0;@@ -2994,11 +3120,14 @@ function applySnapshot(sn){     return inv;   }   if(sn.type==='phase.order'){-    const inv={type:'phase.order',order:phases.map(p=>p.uid||p.name)};-    const key=p=>p.uid||p.name;+    // Keyed by phaseKey, never by name. Until 0.58.0 this was p.uid||p.name, and a plan+    // whose sections carry no uid (the instaPCB plan has none) and two sections with the+    // same name resolved both keys to the FIRST of them: phases[] came back holding one+    // object twice, a section was gone and its rows merged into the other, and it saved.+    const inv={type:'phase.order',order:phases.map(phaseKey)};     const want=sn.order;-    const arr=want.map(k=>phases.find(p=>key(p)===k)).filter(Boolean);-    if(arr.length!==phases.length)return null;+    const arr=want.map(k=>phases.find(p=>phaseKey(p)===k)).filter(Boolean);+    if(arr.length!==phases.length||new Set(arr).size!==arr.length)return null;     const map=new Map();     arr.forEach((p,ni)=>map.set(phases.indexOf(p),ni));     applyPhaseOrder(arr,map);@@ -3104,7 +3233,7 @@ function isTypingTarget(el){ const ESC_TEXT_TYPES={text:1,search:1,number:1,tel:1,url:1,email:1,password:1,   date:1,month:1,week:1,time:1,'datetime-local':1}; // The fields whose Escape is already spoken for. See the listener in wireGlobalKeys.-const ESC_OWNED='.label-edit,.bar-note-edit,.cell-input,.cl-new-inp,.id-box,.sec-back,#editor';+const ESC_OWNED='.label-edit,.bar-note-edit,.cell-input,.cl-new-inp,.id-box,.sec-row,#editor'; function isEscBlurTarget(el){   if(!el||!el.tagName)return false;   const t=el.tagName.toUpperCase();@@ -3129,6 +3258,11 @@ function appIsIdle(){ // and Ctrl+C has never asked the question either (it has answered from behind an open // panel since 0.44.0). Every other clause is kept, and the one that matters most is // kept in both: a field with the caret in it owns its own copy and its own paste.+// The row chords (Alt+Up/Down to move a row, Ctrl+C and Ctrl+V to copy and paste rows)+// act on rows, so they answer only in a lens that shows rows. Until 0.58.0 they also+// answered on Analytics and Setup, where a selection left over from the chart moved or+// pasted rows nobody could see, with no feedback at all.+function rowsLens(){ return prefs.view==='gantt'||prefs.view==='table'; } function clipboardReady(){   if(tblEditing||labelEditing||rowDrag||laneDrag||dragState||reorderState||gesture)return false;   if(datePop&&datePop.classList.contains('show'))return false;@@ -3173,7 +3307,7 @@ document.addEventListener('mousedown',()=>{ if(delArm)disarmDelete(); if(depArm) //   .cell-input     a table cell editor               Escape cancels the edit //   .cl-new-inp     the owner list's type-in row      Escape closes the list //   .id-box         the copy-IDs fallback box         Escape dismisses the box-//   .sec-back       the sections modal                Escape closes the open row editor, then the modal+//   .sec-row        a Setup page row editor           Escape cancels the row's edit //   #editor         every panel field                 Escape closes the panel // // CAPTURE, and it stops the event dead. One Escape is one action: blurring the filter box@@ -3210,6 +3344,7 @@ document.addEventListener('keydown',e=>{   // race: with Alt the plan changes, without it only the selection does.   if(e.altKey&&!e.ctrlKey&&!e.metaKey&&(e.key==='ArrowUp'||e.key==='ArrowDown')){     if(isTypingTarget(document.activeElement))return;+    if(!rowsLens())return;   // Analytics and Setup show no rows to move     e.preventDefault();     moveFocusedRow(e.key==='ArrowDown'?1:-1);     return;@@ -3326,6 +3461,7 @@ document.addEventListener('keydown',e=>{   // that is the hand-off to an AI chat and paste does not replace it.   if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&!e.altKey&&(e.key==='c'||e.key==='C')){     if(!selected.size)return;+    if(!rowsLens())return;     if(!clipboardReady())return;     let live=''; try{ live=String(window.getSelection?window.getSelection().toString():''); }catch(_){}     if(live)return;@@ -3342,6 +3478,7 @@ document.addEventListener('keydown',e=>{   // paste is left alone in every case the gate refuses.   if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&!e.altKey&&(e.key==='v'||e.key==='V')){     if(!clipboardReady())return;+    if(!rowsLens())return;     e.preventDefault(); pasteRows();     return;   }@@ -3353,9 +3490,10 @@ document.addEventListener('keydown',e=>{     return;   }   if(rowDrag||laneDrag){ endRowDrag(false); endLaneDrag(false); return; }-  // The sections modal owns Escape while it is up; its own handler runs first and-  // stops this one, so reaching here means it is already closed.-  if(secModal){ closeSections(); return; }+  // An open section row editor on the Setup page. Its text fields take their own+  // Escape before this runs; this is the rung for when the focus is on one of its+  // buttons or its destination list instead, so Escape still means "cancel the row".+  if(prefs.view==='setup'&&secRowOpen()){ renderSections(); return; }   // Innermost first here too: a picked arrow, then the half-made dependency, then the   // mode that is making them. An ARMED arrow is inside all of them and never reaches   // here at all, because the capturing withdrawal listener above takes that Escape.@@ -3476,6 +3614,16 @@ function applyState(state){   }   const byUid=new Map();   items.forEach(r=>{ if(r.uid)byUid.set(r.uid,r); });+  // How many sections the plan will have once this sidecar is applied. The row overrides+  // are applied BEFORE the section list is rebuilt below, so checking a row's saved phase+  // against phases[] here checked it against the DATA FILE's list. A row whose section+  // sat past the end of that list (any section added since, or one moved up past a new+  // one) failed the check, silently kept its data-file index and landed in whichever+  // section now held that index: reorder a new section and reload, and the rows of the+  // section it passed moved into it. Present since sections became editable (0.57.1 had+  // it too); the server's merge was right all along, so only the page was wrong.+  const sphFull=Array.isArray(state.phases)&&state.phases.length&&state.phases.some(p=>p&&p.name!==undefined);+  const phaseN=sphFull?state.phases.length:phases.length;   (state.items||[]).forEach(sv=>{     if(!sv||!sv.uid)return;     const r=byUid.get(sv.uid);if(!r)return;@@ -3489,7 +3637,7 @@ function applyState(state){     if(sv.desc!==undefined)setOpt(r,'description',sv.desc);     if(sv.prov!==undefined)setProv(r,sv.prov);     if(sv.name)r.name=sv.name;-    if(typeof sv.phase==='number'&&phases[sv.phase])r.phase=sv.phase;+    if(Number.isInteger(sv.phase)&&sv.phase>=0&&sv.phase<phaseN)r.phase=sv.phase;   });   // The sidecar's phase list, applied EXACTLY as the server's mergedData does, so   // the client renders what /export/json says. Two shapes. A sidecar written since@@ -3727,7 +3875,7 @@ function flushLogQueue(){   fetch('log',{method:'POST',headers:{'Content-Type':'application/json'},body:logBody(q)})     .catch(()=>{}); }-function scheduleSave(via){diffAndLog(via||'edit');syncStartBtn();syncCollapseAllBtn();if(critOn())refreshCritical();syncPlanCtl();if(prefs.view==='analytics')renderAnalytics();clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},800);}+function scheduleSave(via){diffAndLog(via||'edit');syncStartBtn();syncCollapseAllBtn();if(critOn())refreshCritical();syncPlanCtl();if(prefs.view==='analytics')renderAnalytics();else if(prefs.view==='setup')renderSections(true);clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},800);} // Both writes wait: the sidecar for its 800ms debounce and, since 0.54.0, the changelog // line for the save it belongs to. A tab closed inside that window would leave the edit in // neither, so anything still pending goes out synchronously when the page is being put@@ -5748,9 +5896,13 @@ function refreshSections(){   // Every phase dropdown (the New panel, the table's phase cell) is built from   // phases[] at the moment it opens, so there is nothing else to keep in step.   if(prefs.view==='table')renderTable();+  // The Setup page is the section list, so it follows every change, including one that+  // came from Ctrl+Z or from another tab rather than from its own buttons.+  if(prefs.view==='setup')renderSections(true); } function renamePhase(i,name){   const p=phases[i]; if(!p||!name||name===p.name)return false;+  if(secNameTaken(name,p))return false;   // two sections may not share a name   const from=p.name;   pushUndo({type:'phase.rename',i,name:from});   p.name=name;@@ -5764,9 +5916,16 @@ function renamePhase(i,name){ // new array, build old-index -> new-index from it, then rewrite every row. The // mapping is asserted to be a bijection before anything is applied, because a // half-applied phase remap is the restructure hazard all over again.+// A client-only identity for a section, for the undo entries that have to find one again.+// A uid is not enough (older plans have none on their phases, and the server only mints+// them for rows) and a name is not one at all. `_k` is minted once per phase object and is+// copied along with it by Object.assign, so the copies phaseSnapshot keeps and+// restorePhaseState puts back are still the same section. It never reaches the disk: every+// writer of phases[] (the sidecar, the export) lists the fields it writes.+function phaseKey(p){ if(!p._k)p._k='k'+(++phaseKeySeq); return p._k; } function reorderPhases(from,to){   if(from===to||!phases[from]||to<0||to>=phases.length)return false;-  const before=phases.map(p=>p.uid||p.name);+  const before=phases.map(phaseKey);   const arr=phases.slice();   const [moved]=arr.splice(from,1);   arr.splice(to,0,moved);@@ -5804,6 +5963,7 @@ function applyPhaseOrder(arr,map){ // back in the section they came from rather than leaving them where they were // reassigned to. function phaseSnapshot(){+  phases.forEach(phaseKey);   // keyed BEFORE the copy, so the copies carry the key   return {type:'phase.state',     phases:phases.map(p=>Object.assign({},p)),     at:items.filter(r=>!isDerivedGroup(r)).map(r=>[r.uid,r.phase]),@@ -5905,13 +6065,19 @@ function logPhaseEvent(e){     body:JSON.stringify({actor:'user',events:[Object.assign({via:'sections'},e)]})}).catch(()=>{}); }catch(_){} } -// ── Sections editor ────────────────────────────────────────────────────────+// ── Sections editor: the Setup lens ────────────────────────────────────────── //-// The modal is a view of phases[] and nothing else: every button calls one of the+// The page is a view of phases[] and nothing else: every button calls one of the // mutation functions above, then the list is rebuilt from phases[]. There is no // second copy of the section list to fall out of step, which is the whole reason // unification came first. //+// 0.58.0 moved it from a modal behind the header's Sections button onto the Setup+// lens. The row code is the modal's, unchanged in what it does; what changed is the+// frame. A page is never "open" or "closed", so the modal's own Escape handler and+// its Done button went with it: Escape on the page belongs to the row editor that is+// open (see ESC_OWNED and the app-level stack), and leaving is picking another lens.+// // A brand-safe palette, plus a constrained hex box for anything else. "Theme" is // the absence of a colour: it hands the lane back to the --ph ramp, which is the // only setting that adapts between light and dark. Anything explicit is the colour@@ -5919,59 +6085,107 @@ function logPhaseEvent(e){ const SEC_PALETTE=[['','Theme'],['#00b8b1','Teal'],['#8c6bf7','Purple'],['#64abff','Blue'],   ['#3fb950','Green'],['#f0883e','Orange'],['#f85149','Red'],['#8b949e','Slate']]; const HEX_RE=/^#[0-9a-fA-F]{6}$/;-let secModal=null;-function closeSections(){-  if(!secModal)return;-  secModal.remove(); secModal=null;-  document.removeEventListener('keydown',secKeys,true);-}-function secKeys(e){-  if(e.key!=='Escape'||!secModal)return;-  // Innermost first, same stack as everywhere else: an open colour picker or an-  // open name field takes the Escape before the modal does.-  const open=secModal.querySelector('.sec-row.sec-editing');-  if(open){ e.stopPropagation(); e.preventDefault(); renderSections(); return; }-  e.stopPropagation(); e.preventDefault(); closeSections();-} function laneCount(i){ return items.filter(r=>!isDerivedGroup(r)&&r.phase===i).length; }-function openSections(){-  closeMenus(); closeEditor(); closeSections();-  const back=document.createElement('div'); back.className='sec-back';-  back.innerHTML='<div class="sec-modal" role="dialog" aria-modal="true" aria-label="Sections">'+-    '<div class="sec-head"><h2>Sections</h2><span class="sec-hint">the lanes of this plan, in order</span></div>'+-    '<div class="sec-list"></div><div class="sec-err" style="display:none"></div>'+-    '<div class="sec-foot"><input type="text" id="secNew" placeholder="New section name" maxlength="60">'+-    '<button class="de-btn primary" id="secAdd">Add section</button>'+-    '<button class="de-btn secondary" id="secDone">Done</button></div></div>';-  document.body.appendChild(back);-  secModal=back;-  back.addEventListener('mousedown',e=>{ if(e.target===back)closeSections(); });-  document.addEventListener('keydown',secKeys,true);-  back.querySelector('#secDone').addEventListener('click',closeSections);+// The dates a section's rows span, first to last, the same reduction the Analytics+// per-section card makes: a task counts its start and end, a milestone its date, and+// a row with no usable date adds nothing. A section's own start and end in the data+// are not used, because what the chart draws for a lane is where its rows are.+function laneSpan(i){+  let lo='',hi='';+  items.forEach(r=>{+    if(isDerivedGroup(r)||r.phase!==i)return;+    const ds=isMs(r)?[r.date]:(r.start&&r.end?[r.start,r.end]:[]);+    ds.forEach(d=>{ if(!d)return;+      if(!lo||dayNum(d)<dayNum(lo))lo=d;+      if(!hi||dayNum(d)>dayNum(hi))hi=d; });+  });+  return {from:lo,to:hi};+}+// Is a row's inline editor (rename, colour, remove) open?+function secRowOpen(){ const v=$('setupView'); return !!(v&&v.querySelector('.sec-row.sec-editing')); }+// Section names are how people, the legend, the table's phase column and every phase+// dropdown tell sections apart, so two sections may not share one. Compared trimmed and+// without regard to case, because "Fab" and "fab " are the same word to a reader. It was+// allowed until 0.58.0, and the reorder undo, which found sections by name, merged two+// same-named sections into one.+function secNameTaken(name,except){+  const k=String(name||'').trim().toLowerCase();+  return phases.some(p=>p!==except&&String(p.name||'').trim().toLowerCase()===k);+}+// The colour box takes what people actually type: with or without the #, three digits or+// six, either case. It is stored as lowercase #rrggbb, so the palette chip for the same+// colour lights up. null means it is not a colour at all.+function normHex(v){+  v=String(v||'').trim().toLowerCase(); if(!v)return '';+  if(v[0]!=='#')v='#'+v;+  if(/^#[0-9a-f]{3}$/.test(v))v='#'+v[1]+v[1]+v[2]+v[2]+v[3]+v[3];+  return HEX_RE.test(v)?v:null;+}+// Several phase mutations made by ONE gesture (a Save that renames and sets the aside)+// become one undo entry: while `undoGroup` is an array, pushUndo collects into it, and+// the collection goes on the stack as a single `multi`, which applySnapshot already+// knows how to undo and redo as a unit. The page says every change is one Ctrl+Z.+function withOneUndo(fn){+  const g=undoGroup=[];+  try{ fn(); } finally{ undoGroup=null; }+  if(g.length===1)pushUndo(g[0]);+  else if(g.length>1)pushUndo({type:'multi',steps:g.reverse()});+}+function wireSetup(){+  const v=$('setupView'); if(!v)return;+  const inp=$('secNew'), btn=$('secAdd'), err=v.querySelector('.sec-err');+  const say=t=>{ err.textContent=t; err.style.display=t?'':'none'; };+  // One add at a time. The id comes from the server, so there is a window between the+  // press and the new row; the button was disabled across it but Enter was not, and five+  // quick Enters made five sections of the same name.+  let adding=false;   const add=async()=>{-    const inp=back.querySelector('#secNew'); const nm=inp.value.trim();-    const err=back.querySelector('.sec-err');+    if(adding)return;+    const nm=inp.value.trim();     if(!nm){ inp.focus(); return; }-    const btn=back.querySelector('#secAdd'); btn.disabled=true;-    try{ await createPhase(nm,'',''); inp.value=''; err.style.display='none'; renderSections(); }-    catch(e){ err.textContent='No id could be allocated, so no section was created ('+e.message+').'; err.style.display=''; }-    btn.disabled=false;+    if(secNameTaken(nm)){ say('There is already a section called "'+nm+'". Pick a name no other section has.'); inp.focus(); return; }+    adding=true; btn.disabled=true;+    try{+      const i=await createPhase(nm,'','');+      inp.value=''; say(''); renderSections();+      // New sections go on the END of the list, which on a twenty-section plan is off+      // the bottom of the card, so the new row is brought into view and flashed once:+      // otherwise the only sign it worked is that the name box emptied.+      const row=v.querySelector(`.sec-row[data-i="${i}"]`);+      if(row){ row.scrollIntoView({block:'nearest'}); row.classList.add('sec-flash'); }+    }+    catch(e){ say('No id could be allocated, so no section was created ('+e.message+').'); }+    finally{ adding=false; btn.disabled=false; }   };-  back.querySelector('#secAdd').addEventListener('click',add);-  back.querySelector('#secNew').addEventListener('keydown',e=>{ e.stopPropagation(); if(e.key==='Enter')add(); });-  renderSections();-  back.querySelector('#secNew').focus();-}-function renderSections(){-  if(!secModal)return;-  const list=secModal.querySelector('.sec-list');+  btn.addEventListener('click',add);+  inp.addEventListener('keydown',e=>{ e.stopPropagation(); if(e.key==='Enter'){ e.preventDefault(); add(); } });+  inp.addEventListener('input',()=>say(''));+}+// Rebuild the list from phases[]. With `keep`, an open row editor survives the rebuild:+// it is found again by its section's phaseKey (NOT by its index, which an undo, a redo+// or another tab's reorder can change under it), reopened on that section's new row+// with whatever had been typed into it, and dropped only if the section itself is gone.+// Every passive refresh keeps; a deliberate close (Save, Cancel, Escape) does not.+//+// Until 0.58.0's review the editor was left alone instead, stale, and its buttons still+// carried the index they were opened with, so Ctrl+Z with a Remove open, followed by the+// Remove, deleted whichever section had moved into that slot and merged its rows.+function renderSections(keep){+  const v=$('setupView'); if(!v)return;+  const list=v.querySelector('.sec-list');+  let held=null;+  if(keep){ const ed=list.querySelector('.sec-row.sec-editing'); if(ed&&ed._secHold)held=ed._secHold(); }   list.innerHTML='';+  const tot=$('secTotal'); if(tot)tot.textContent=phases.length+' section'+(phases.length===1?'':'s');   phases.forEach((p,i)=>{-    const n=laneCount(i);+    const n=laneCount(i), sp=laneSpan(i);     const row=document.createElement('div'); row.className='sec-row'; row.dataset.i=String(i);+    row.setAttribute('role','listitem');     row.innerHTML=`<button class="sec-sw" title="Colour" style="background:${esc(phaseColor(i))}"></button>`+-      `<span class="sec-name">${esc(p.name)}${p.lead?`<span class="sec-lead">${esc(p.lead)}</span>`:''}</span>`++      `<span class="sec-name" title="${esc(p.name)}${p.lead?' · '+esc(p.lead):''}">${esc(p.name)}${p.lead?`<span class="sec-lead">${esc(p.lead)}</span>`:''}</span>`+       `<span class="sec-count">${n} row${n===1?'':'s'}</span>`++      (sp.from?`<span class="sec-dates">${esc(fmt(sp.from))}${sp.to!==sp.from?' to '+esc(fmt(sp.to)):''}</span>`+              :`<span class="sec-dates none">no dates</span>`)+       `<span class="sec-acts">`+         `<button class="sec-btn" data-a="up" title="Move up"${i===0?' disabled':''}>&uarr;</button>`+         `<button class="sec-btn" data-a="down" title="Move down"${i===phases.length-1?' disabled':''}>&darr;</button>`+@@ -5982,88 +6196,129 @@ function renderSections(){   });   list.querySelectorAll('.sec-row').forEach(row=>{     const i=+row.dataset.i;-    row.querySelector('.sec-sw').addEventListener('click',()=>openSwatch(row,i));+    // The swatch toggles its own palette: a second click on it closes it again.+    row.querySelector('.sec-sw').addEventListener('click',()=>{+      const was=row.classList.contains('sec-editing')&&row.querySelector('.sec-pal');+      renderSections(); if(!was)openSwatch(rowAt(i),i); });     row.querySelectorAll('.sec-btn').forEach(b=>b.addEventListener('click',()=>{       const a=b.dataset.a;-      if(a==='up'&&i>0){ reorderPhases(i,i-1); renderSections(); }-      else if(a==='down'&&i<phases.length-1){ reorderPhases(i,i+1); renderSections(); }-      else if(a==='edit') openEdit(row,i);-      else if(a==='del') openDelete(row,i);+      if(a==='up'&&i>0){ reorderPhases(i,i-1); renderSections(); keepRowFocus(i-1,'up'); }+      else if(a==='down'&&i<phases.length-1){ reorderPhases(i,i+1); renderSections(); keepRowFocus(i+1,'down'); }+      // One row editor at a time: opening a second one closes the first, and what was+      // typed into the first is dropped, the way clicking into another table cell ends+      // the one before. Two open forms on one page is two half-finished edits and no+      // way to tell which Enter saves. Switching lens, by contrast, keeps it.+      else if(a==='edit'){ renderSections(); openEdit(rowAt(i),i); }+      else if(a==='del'){ renderSections(); openDelete(rowAt(i),i); }     }));   });+  if(held){+    const j=phases.findIndex(q=>phaseKey(q)===held.k);+    if(j>=0)held.reopen(rowAt(j),j);+  } }-function openSwatch(row,i){+function rowAt(i){ return $('setupView').querySelector(`.sec-row[data-i="${i}"]`); }+// Where a section is NOW. Every row editor resolves its section through this at the+// moment it acts, never through the index it was opened with.+function phaseIndexOfKey(k){ return phases.findIndex(q=>phaseKey(q)===k); }+// Moving a section rebuilds the list, which would drop the keyboard focus on the floor.+// It lands on the same arrow in the section's NEW row, so pressing Enter again keeps+// moving the same section, and falls back to the other arrow at either end.+function keepRowFocus(i,a){+  const row=rowAt(i); if(!row)return;+  let b=row.querySelector(`.sec-btn[data-a="${a}"]`);+  if(b&&b.disabled)b=row.querySelector(`.sec-btn[data-a="${a==='up'?'down':'up'}"]`);+  if(b)b.focus();+}+// An inline message under a row editor, for the refusals (a taken name, a bad colour).+function secRowMsg(wrap,t){+  let m=wrap.querySelector('.sec-msg');+  if(!m){ m=document.createElement('div'); m.className='sec-msg'; wrap.appendChild(m); }+  m.textContent=t;+}+function openSwatch(row,i,held){   row.classList.add('sec-editing');-  const acts=row.querySelector('.sec-acts');-  const cur=phases[i].color||'';+  const k=phaseKey(phases[i]);+  const cur=(phases[i].color||'').toLowerCase();   const wrap=document.createElement('div');   wrap.className='sec-pal'; wrap.style.cssText='width:100%;order:9';   wrap.innerHTML=SEC_PALETTE.map(([hex,nm])=>-      `<button class="sec-chip${(cur||'')===hex?' on':''}" data-hex="${hex}" title="${esc(nm)}" style="background:${hex||'var(--ph'+((i%3)+1)+')'}"></button>`).join('')+-    `<input type="text" class="sec-hex" placeholder="#rrggbb" maxlength="7" value="${esc(cur)}" style="width:88px;margin-left:6px">`;+      `<button class="sec-chip${cur===hex?' on':''}" data-hex="${hex}" title="${esc(nm)}" style="background:${hex||'var(--ph'+((i%3)+1)+')'}"></button>`).join('')++    `<input type="text" class="sec-hex" placeholder="#rrggbb" maxlength="7" value="${esc(held?held.hex:cur)}" style="width:88px;margin-left:6px" aria-label="Colour as #rrggbb">`;   row.appendChild(wrap);-  wrap.querySelectorAll('.sec-chip').forEach(c=>c.addEventListener('click',()=>{-    recolorPhase(i,c.dataset.hex); renderSections();-  }));   const hex=wrap.querySelector('.sec-hex');+  row._secHold=()=>({k,reopen:(r,j)=>openSwatch(r,j,{hex:hex.value})});+  const apply=c=>{ const j=phaseIndexOfKey(k); if(j>=0)recolorPhase(j,c); renderSections(); };+  wrap.querySelectorAll('.sec-chip').forEach(c=>c.addEventListener('click',()=>apply(c.dataset.hex)));   hex.addEventListener('keydown',e=>{ e.stopPropagation();-    if(e.key==='Enter'){ const v=hex.value.trim();-      if(v&&!HEX_RE.test(v)){ hex.style.borderColor='#f85149'; return; }-      recolorPhase(i,v); renderSections(); }+    if(e.key==='Enter'){ const nv=normHex(hex.value);+      if(nv===null){ hex.style.borderColor='#f85149';+        secRowMsg(wrap,'"'+hex.value.trim()+'" is not a colour. Type six hex digits, like #3fb950, or three, like #3b5.'); return; }+      apply(nv); }     else if(e.key==='Escape'){ renderSections(); } });-  hex.focus();+  if(!held)hex.focus(); }-function openEdit(row,i){+function openEdit(row,i,held){   row.classList.add('sec-editing');-  const p=phases[i];+  const p=phases[i], k=phaseKey(p);   const wrap=document.createElement('div');-  wrap.style.cssText='display:flex;gap:6px;width:100%;order:9;padding-top:6px';-  wrap.innerHTML=`<input type="text" class="sec-nm" value="${esc(p.name)}" maxlength="60" style="flex:1">`+-    `<input type="text" class="sec-ld" value="${esc(p.lead||'')}" maxlength="60" placeholder="aside, e.g. the long pole (Adonis)" style="flex:1">`++  wrap.style.cssText='display:flex;flex-wrap:wrap;gap:6px;width:100%;order:9;padding:6px 0 0 25px';+  wrap.innerHTML=`<input type="text" class="sec-nm" value="${esc(held?held.nm:p.name)}" maxlength="60" style="flex:1 1 180px;min-width:0" aria-label="Section name">`++    `<input type="text" class="sec-ld" value="${esc(held?held.ld:(p.lead||''))}" maxlength="60" placeholder="aside, e.g. the long pole (Adonis)" style="flex:1 1 180px;min-width:0" aria-label="Aside">`+     `<button class="de-btn primary sec-ok" style="padding:2px 10px">Save</button>`;   row.appendChild(wrap);   const nm=wrap.querySelector('.sec-nm'), ld=wrap.querySelector('.sec-ld');+  row._secHold=()=>({k,reopen:(r,j)=>openEdit(r,j,{nm:nm.value,ld:ld.value})});   const save=()=>{-    const v=nm.value.trim();-    if(v&&v!==p.name)renamePhase(i,v);-    setPhaseLead(i,ld.value.trim());+    const j=phaseIndexOfKey(k); if(j<0){ renderSections(); return; }+    const q=phases[j], v=nm.value.trim(), lead=ld.value.trim();+    if(v&&v!==q.name&&secNameTaken(v,q)){+      nm.style.borderColor='#f85149';+      secRowMsg(wrap,'There is already a section called "'+v+'". Pick a name no other section has.');+      nm.focus(); return;+    }+    withOneUndo(()=>{ if(v&&v!==q.name)renamePhase(j,v); setPhaseLead(j,lead); });     renderSections();   };   wrap.querySelector('.sec-ok').addEventListener('click',save);   [nm,ld].forEach(el=>el.addEventListener('keydown',e=>{ e.stopPropagation();     if(e.key==='Enter')save(); else if(e.key==='Escape')renderSections(); }));-  nm.focus(); nm.select();+  if(!held){ nm.focus(); nm.select(); } }-function openDelete(row,i){-  const n=laneCount(i);+function openDelete(row,i,held){+  const n=laneCount(i), k=phaseKey(phases[i]);   row.classList.add('sec-editing');   const wrap=document.createElement('div');-  wrap.style.cssText='display:flex;gap:6px;width:100%;order:9;padding-top:6px;align-items:center;flex-wrap:wrap';+  wrap.style.cssText='display:flex;gap:6px;width:100%;order:9;padding:6px 0 0 25px;align-items:center;flex-wrap:wrap';   if(!n){     wrap.innerHTML=`<span style="font-size:11px;color:var(--text2);flex:1">Remove "${esc(phases[i].name)}"? It has no rows.</span>`+       `<button class="de-btn primary sec-go" style="padding:2px 10px">Remove</button>`+       `<button class="de-btn secondary sec-no" style="padding:2px 10px">Cancel</button>`;   } else {     // A populated section cannot just go: its rows have to land somewhere, and the-    // user says where. There is no option that discards them.-    const opts=phases.map((p,j)=>j===i?'':`<option value="${j}">${esc(p.name)}</option>`).join('');+    // user says where. There is no option that discards them. The options carry the+    // destination's phaseKey, not its index, for the same reason the editor does.+    const opts=phases.map((p,j)=>j===i?'':`<option value="${esc(phaseKey(p))}">${esc(p.name)}</option>`).join('');     wrap.innerHTML=`<span style="font-size:11px;color:var(--text2);flex:1 1 100%">`+       `"${esc(phases[i].name)}" holds ${n} row${n===1?'':'s'}. Move ${n===1?'it':'them'} to:</span>`+-      `<select class="sec-dest">${opts}</select>`++      `<select class="sec-dest" style="max-width:100%" aria-label="Move the rows to">${opts}</select>`+       `<button class="de-btn primary sec-go" style="padding:2px 10px">Move and remove</button>`+       `<button class="de-btn secondary sec-no" style="padding:2px 10px">Cancel</button>`;   }   row.appendChild(wrap);+  const sel=wrap.querySelector('.sec-dest');+  if(sel&&held&&held.dest&&[...sel.options].some(o=>o.value===held.dest))sel.value=held.dest;+  row._secHold=()=>({k,reopen:(r,j)=>openDelete(r,j,{dest:sel?sel.value:''})});   wrap.querySelector('.sec-no').addEventListener('click',()=>renderSections());   wrap.querySelector('.sec-go').addEventListener('click',()=>{-    const sel=wrap.querySelector('.sec-dest');-    const to=sel?parseInt(sel.value,10):undefined;-    if(deletePhase(i,to))renderSections();+    const j=phaseIndexOfKey(k); if(j<0){ renderSections(); return; }+    // The row count is read again too: an undo can have put rows into a section that+    // was empty when the form opened, and deletePhase refuses to orphan them.+    let to;+    if(sel){ to=phaseIndexOfKey(sel.value); if(to<0){ renderSections(true); return; } }+    if(deletePhase(j,to))renderSections(); else renderSections(true);   });-}-function wireSectionsButton(){-  const b=$('btnSections');-  if(b)b.addEventListener('click',e=>{ e.stopPropagation(); openSections(); });+  if(!held)(sel||wrap.querySelector('.sec-no')).focus(); }  // ── Milestone editor ──@@ -7279,6 +7534,7 @@ function shiftPlanTo(newStartISO, via){   // Anchor on the 15th, not the 1st. Adding days to the 1st lands mid-month and then   // truncates down, so a +181d shift moved some phases 6 months and others 5.   phases.forEach(p=>{+    if(!phaseHasRange(p))return;   // nothing to move, and '' would become a bogus month     p.start=addDays(p.start+'-15',delta).slice(0,7);     const[ey,em]=p.end.split('-').map(Number);     p.end=addDays(`${ey}-${String(em).padStart(2,'0')}-15`,delta).slice(0,7);@@ -10199,10 +10455,12 @@ function renderAnalytics(){ }  // ── View switching ──-// Three lenses now. `VIEWS` is the one list: the toggle is built from the markup,-// the preference is validated against this, and an unknown value (an old preference,-// a hand-edited file) falls back to the chart rather than showing nothing.-const VIEWS=['gantt','table','analytics'];+// Four lenses since 0.58.0. `VIEWS` is the one list: the toggle is built from the+// markup, the preference is validated against this, and an unknown value (an old+// preference, a hand-edited file) falls back to the chart rather than showing nothing.+// viewDefaults() reads it too, which is why it is VIEW_LIST, declared up with the+// prefs: that runs before this line does.+const VIEWS=VIEW_LIST; function applyView(){   const v = VIEWS.includes(prefs.view) ? prefs.view : 'gantt';   document.documentElement.setAttribute('data-view',v);@@ -10227,6 +10485,8 @@ function setView(v){   // kept warm: whatever changed in another lens is already in `items`, and reading   // it once here is cheaper and less to get wrong than keeping a second copy live.   if(v==='analytics'){renderAnalytics();return;}+  // Setup likewise: the row counts and date ranges are read from `items` on the way in.+  if(v==='setup'){renderSections(true);return;}   if(v==='table'){renderTable();return;}   // The chart measured nothing while it was hidden, so lay it out from scratch   // once it is back on screen. Through the app's own debounced path with the echo@@ -10329,6 +10589,7 @@ if(critOn()&&!planHasDeps()){ } if(critOn())refreshCritical(); if(prefs.view==='analytics')renderAnalytics();+if(prefs.view==='setup')renderSections(); healStoredPrefs(); rebasePrefs(); // Say it after the first paint, so the message sits over a chart that clearly@@ -10407,7 +10668,7 @@ async function init(){   wireBarDblclick();   wireTooltipsAndExport();   wireImportAndHelp();-  wireSectionsButton();+  wireSetup();   wireThemeButton();   wireMsStyleAndGrain();   wireDepMode();
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.57.1",+  "version": "0.58.0",   "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",   "main": "server.js",   "bin": {
page.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.57.1",+  "version": "0.58.0",   "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",   "main": "server.js",   "bin": {
server.js+120−9
@@ -665,6 +665,21 @@ function describeEvent(e) {     if (!t) return `${who} cleared the notes on ${name}${via}`;     return `${who} rewrote the notes on ${name}: "${t}"${via}`;   }+  // Tags. Both sides are read defensively: anyone can POST /log, so a `tags` that+  // is not a list of strings renders as whatever strings it does hold, or none.+  if (action === 'tags' || action === 'phase.tags') {+    const list = side => {+      const o = e[side];+      const v = o && typeof o === 'object' ? o.tags : undefined;+      return (Array.isArray(v) ? v : []).filter(t => typeof t === 'string' && t.trim()).map(t => oneLine(t));+    };+    const f = list('from'), t = list('to');+    const target = action === 'tags' ? name+      : `section ${e.name ? `"${e.name}"` : (typeof e.phase === 'number' ? '#' + (e.phase + 1) : '?')}`;+    const was = f.length ? f.join(', ') : 'none';+    if (!t.length) return `${who} cleared the tags on ${target} (was ${was})${via}`;+    return `${who} tagged ${target} with ${t.join(', ')} (was ${was})${via}`;+  }   if (action === 'rename') {     return `${who} renamed "${fld('from', 'name', '?')}" to "${fld('to', 'name', '?')}"${via}`;   }@@ -849,6 +864,62 @@ function setOpt(r, k, v) {   else r[k] = v; } +// ── Tags: short strings on a section (phases[].tags) or a row (items[].tags) ──+//+// ONE normaliser, used everywhere the server reads a tag list: the data file on+// the way out, the sidecar, the /save body, every importer. Trim, drop empties,+// drop anything that is not a string, de-duplicate without regard to case (the+// first spelling wins), cap each tag at 32 characters and the list at 12. Kept in+// step with the client's copy for the same reason statusKey is.+//+// Returns an ARRAY, possibly empty, for an array in, and undefined for anything+// else (a malformed value is dropped, never an error). The two callers want+// different things from an empty result: the data file stores no `tags` key at+// all (absent, not []), so a plan with no tags round-trips byte for byte, while+// the sidecar keeps an explicit [] because there it means "the user cleared it".+const TAG_MAX_LEN = 32, TAG_MAX_COUNT = 12;+function normalizeTags(v) {+  if (!Array.isArray(v)) return undefined;+  const out = [], seen = new Set();+  for (const t of v) {+    if (typeof t !== 'string') continue;+    const s = t.trim().slice(0, TAG_MAX_LEN).trim();+    if (!s || seen.has(s.toLowerCase())) continue;+    seen.add(s.toLowerCase());+    out.push(s);+    if (out.length >= TAG_MAX_COUNT) break;+  }+  return out;+}+// Data-file rule: a non-empty list or no key at all. Touches nothing when the+// object never had the key, so an untagged plan is not rewritten in any way.+function tidyTags(o) {+  if (!o || typeof o !== 'object' || !('tags' in o)) return o;+  const t = normalizeTags(o.tags);+  if (t && t.length) o.tags = t; else delete o.tags;+  return o;+}+function tidyPlanTags(data) {+  if (!data || typeof data !== 'object') return data;+  if (Array.isArray(data.phases)) data.phases.forEach(tidyTags);+  if (Array.isArray(data.items)) data.items.forEach(tidyTags);+  return data;+}+// Sidecar rule: an array (normalised, [] kept as "cleared") or no key. Applies to+// the v4 override lists; `added` rows are whole rows and take the data-file rule.+function tidyStateTags(st) {+  if (!st || typeof st !== 'object' || Array.isArray(st)) return st;+  const side = o => {+    if (!o || typeof o !== 'object' || !('tags' in o)) return;+    const t = normalizeTags(o.tags);+    if (t) o.tags = t; else delete o.tags;+  };+  if (Array.isArray(st.items)) st.items.forEach(side);+  if (Array.isArray(st.phases)) st.phases.forEach(side);+  if (Array.isArray(st.added)) st.added.forEach(tidyTags);+  return st;+}+ // provenance is {label, url} or a bare string, so setOpt's rules do not fit. function setProv(r, v) {   if (v === undefined) return;@@ -1279,8 +1350,8 @@ function finaliseMerged(data) {   return data; } function mergedData() {-  const data = loadData();-  const state = upgradeState(loadState(), data);+  const data = tidyPlanTags(loadData());+  const state = tidyStateTags(upgradeState(loadState(), data));   if (!state) return finaliseMerged(data);   // Rows created in the app live only in the sidecar, so every reader of the   // merged plan (json, csv, svg, html) has to fold them back in. Appended; the@@ -1310,6 +1381,15 @@ function mergedData() {   // milestone had no stable key to be found by. It has one now.   const byUid = new Map();   data.items.forEach(r => { if (r.uid) byUid.set(r.uid, r); });+  // How many sections the plan has once this sidecar is applied. The row overrides run+  // BEFORE the sidecar's full section list replaces data.phases below, so checking a+  // row's saved phase against data.phases checked it against the DATA FILE's list: a+  // row whose section sat past the end of it (a section added since, or one moved down+  // past one) failed the check, kept its data-file index and was merged into whichever+  // section held that index. Came in with 0.57.1; the page had the same bug and the+  // same fix.+  const fullPhases = Array.isArray(state.phases) && state.phases.length && state.phases.some(p => p && p.name !== undefined);+  const phaseCount = fullPhases ? state.phases.length : data.phases.length;   (state.items || []).forEach(sv => {     if (!sv || !sv.uid) return;     const r = byUid.get(sv.uid);@@ -1323,8 +1403,10 @@ function mergedData() {     setOpt(r, 'status', sv.status);     setOpt(r, 'description', sv.desc);     setProv(r, sv.prov);+    // Absent: the data file's tags stand. [] : the user cleared them.+    if (Array.isArray(sv.tags)) { if (sv.tags.length) r.tags = sv.tags.slice(); else delete r.tags; }     if (sv.name) r.name = sv.name;-    if (typeof sv.phase === 'number' && data.phases[sv.phase]) r.phase = sv.phase;+    if (Number.isInteger(sv.phase) && sv.phase >= 0 && sv.phase < phaseCount) r.phase = sv.phase;   });   // Two shapes. A sidecar written since sections became editable carries the FULL   // list, every entry with a name, and it owns the sections outright: that is what@@ -1333,17 +1415,31 @@ function mergedData() {   // is applied against the data file's phases exactly as before.   const sph = state.phases;   if (Array.isArray(sph) && sph.length && sph.some(p => p && p.name !== undefined)) {+    // Tags are newer than this shape, so a full list written by a client that+    // predates them says nothing about tags, and "nothing" must not erase the data+    // file's. An entry with no `tags` key inherits its section's tags from the data+    // file, found by the section's uid and then by its name. [] clears them.+    const basePh = data.phases;+    const baseTags = p => {+      const b = (p.uid && basePh.find(x => x && x.uid === p.uid)) || basePh.find(x => x && x.name === p.name);+      return b && Array.isArray(b.tags) ? b.tags : undefined;+    };     data.phases = sph.map(p => {       const o = { name: p.name, start: p.start || '', end: p.end || '' };       if (p.uid) o.uid = p.uid;       if (p.lead) o.lead = p.lead;       if (p.color) o.color = p.color;+      const t = Array.isArray(p.tags) ? p.tags : baseTags(p);+      if (t && t.length) o.tags = t.slice();       return o;     });   } else {     (sph || []).forEach(sv => {-      const p = data.phases[sv.i];-      if (p) { if (sv.start) p.start = sv.start; if (sv.end) p.end = sv.end; }+      const p = sv && data.phases[sv.i];+      if (p) {+        if (sv.start) p.start = sv.start; if (sv.end) p.end = sv.end;+        if (Array.isArray(sv.tags)) { if (sv.tags.length) p.tags = sv.tags.slice(); else delete p.tags; }+      }     });   }   if (Array.isArray(state.order) && state.order.length === data.items.length) {@@ -1426,7 +1522,7 @@ function csvUnguard(s) { return (typeof s === 'string' && s.length > 1 && s[0] = function itemsToCSV(items, phases) {   // uid leads, because it is the column that survives everything else in the row   // being edited in the spreadsheet, and /import/csv reads it back.-  const headers = ['uid', 'id', 'name', 'start', 'end', 'phase', 'owner', 'status', 'description', 'provenance', 'provenance_url', 'deps', 'type', 'label', 'date', 'striped'];+  const headers = ['uid', 'id', 'name', 'start', 'end', 'phase', 'owner', 'status', 'description', 'provenance', 'provenance_url', 'deps', 'tags', 'type', 'label', 'date', 'striped'];   const escape = v => {     if (v == null) return '';     const s = csvGuard(String(v));@@ -1443,6 +1539,7 @@ function itemsToCSV(items, phases) {   items.forEach(item => {     const row = headers.map(h => {       if (h === 'deps') return escape((item.deps || []).join(';'));+      if (h === 'tags') return escape((normalizeTags(item.tags) || []).join(';'));       if (h === 'phase') return escape(phaseName(item.phase));       // Two columns rather than one packed cell, so an export re-imported keeps       // the label and the link as separate things.@@ -1468,6 +1565,7 @@ function importJSON(body) {     throw new Error('phases and items must be arrays');   }   assertDates(data.items);+  tidyPlanTags(data);   // A JSON import is usually this app's own export coming back, so uids the file   // carries are KEPT: an AI handed a list of numbers must still be able to use   // them after the round trip. Absent, duplicated or malformed ones are minted.@@ -1546,6 +1644,8 @@ function importCSV(body, queryString) {     if (row.status) { item.status = statusKey(row.status); }     if (row.description) { item.description = row.description; }     if (deps.length) { item.deps = deps; }+    const tags = normalizeTags(row.tags ? row.tags.split(';') : []);+    if (tags.length) { item.tags = tags; }     if (row.label) { item.label = row.label; }     if (row.striped === 'true') { item.striped = true; } @@ -1761,6 +1861,9 @@ function importJiraCSV(body) {       status: mappedStatus,       description: description.length > 200 ? description.substring(0, 200) + '...' : description     });+    // Jira's Labels are space-separated words; they map to tags when present.+    const labels = normalizeTags(String(row.labels || '').split(/[\s,;]+/));+    if (labels.length) items[items.length - 1].tags = labels;   });    // Filter out items without any dates@@ -2145,7 +2248,13 @@ const server = http.createServer(async (req, res) => {      if (urlPath === '/data') {       res.writeHead(200, { 'Content-Type': 'application/json' });-      res.end(fs.readFileSync(DATA_FILE, 'utf8'));+      // Byte for byte as stored, unless the file carries tags, and then they go+      // out normalised: a hand-edited list must not reach the client unchecked.+      let raw = fs.readFileSync(DATA_FILE, 'utf8');+      if (raw.includes('"tags"')) {+        try { raw = JSON.stringify(tidyPlanTags(JSON.parse(raw)), null, 2) + '\n'; } catch (e) {}+      }+      res.end(raw);       return;     } @@ -2175,6 +2284,8 @@ const server = http.createServer(async (req, res) => {         if (Array.isArray(store) || store.v !== 4) {           try { store = upgradeState(store, loadData()) || incoming; } catch (e) {}         }+        // A malformed tag list is dropped here, never stored and never a 500.+        tidyStateTags(store);         store.stateRev = cur + 1;         delete store.baseRev;         delete incoming.baseRev;@@ -2267,7 +2378,7 @@ const server = http.createServer(async (req, res) => {       const integrity = integrityStatus();       if (fs.existsSync(STATE_FILE)) {         let out;-        try { out = upgradeState(loadState(), loadData()); }+        try { out = tidyStateTags(upgradeState(loadState(), loadData())); }         catch (e) { out = null; }         if (out) { out.integrity = integrity; jsonResp(res, 200, out); return; }         // Fallback: the raw sidecar, with the integrity object merged in so a@@ -2409,7 +2520,7 @@ const server = http.createServer(async (req, res) => {           // Normalise on the way through, exactly as mergedData does on the way           // out, so a POSTed export speaks the same vocabulary as a GET one.           d.items.forEach(r => { if (r && r.status) r.status = statusKey(r.status); });-          return d;+          return tidyPlanTags(d);         }       } catch (e) {}       return mergedData();
test/server_test.mjsadded+307
@@ -0,0 +1,307 @@+#!/usr/bin/env node+// Server-only tests for tags (0.59/0.60): no browser. Spawns server.js --no-auth on+// ports 8940-8949 against temp copies of a small plan and exercises the contract+// over HTTP. Exits non-zero on any failure.+//+//   node test/server_test.mjs+import { spawn } from 'node:child_process';+import fs from 'node:fs';+import os from 'node:os';+import path from 'node:path';+import net from 'node:net';+import { fileURLToPath } from 'node:url';++const HERE = path.dirname(fileURLToPath(import.meta.url));+const SERVER = path.join(HERE, '..', 'server.js');+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'gantt-srv-test-'));+const procs = [];+let pass = 0, fail = 0;++function ok(cond, msg, extra) {+  if (cond) { pass++; console.log('  ok   ' + msg); }+  else { fail++; console.log('  FAIL ' + msg + (extra !== undefined ? '  :: ' + JSON.stringify(extra).slice(0, 400) : '')); }+}+const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);++function basePlan(withTags) {+  const p = {+    project: { title: 'Tag test', subtitle: 'server_test' },+    phases: [+      { name: 'Tech · Fab', start: '2026-10', end: '2026-11' },+      { name: 'Ops', start: '2026-10', end: '2026-12' },+    ],+    items: [+      { type: 'task', id: 'etch', name: 'Etch the cards', start: '2026-10-05', end: '2026-10-09', phase: 0 },+      { type: 'task', id: 'ship', name: 'Ship', start: '2026-10-12', end: '2026-10-16', phase: 1, deps: ['etch'] },+      { type: 'milestone', name: 'Launch', date: '2026-10-20', phase: 1 },+    ],+  };+  if (withTags) {+    p.phases[0].tags = ['Tech', 'tech', '  '];+    p.items[0].tags = ['long-lead'];+    p.items[2].tags = ['gate'];+  }+  return p;+}++async function portFree(port) {+  return new Promise(res => {+    const s = net.createServer();+    s.once('error', () => res(false));+    s.once('listening', () => s.close(() => res(true)));+    s.listen(port, '127.0.0.1');+  });+}+async function freePort(from) {+  for (let p = from; p <= 8949; p++) if (await portFree(p)) return p;+  throw new Error('no free port in 8940-8949');+}++async function start(name, plan, sidecar, fromPort) {+  const dir = path.join(TMP, name);+  fs.mkdirSync(dir, { recursive: true });+  const data = path.join(dir, 'plan.json');+  fs.writeFileSync(data, JSON.stringify(plan, null, 2) + '\n');+  if (sidecar) fs.writeFileSync(path.join(dir, 'plan-state.json'), JSON.stringify(sidecar));+  const port = await freePort(fromPort);+  const p = spawn(process.execPath, [SERVER, '--data', data, '--port', String(port), '--no-auth'], { stdio: ['ignore', 'pipe', 'pipe'] });+  let log = '';+  p.stdout.on('data', d => { log += d; });+  p.stderr.on('data', d => { log += d; });+  procs.push(p);+  const base = `http://127.0.0.1:${port}`;+  for (let i = 0; i < 100; i++) {+    try { const r = await fetch(base + '/health'); if (r.ok) break; } catch (e) {}+    await new Promise(r => setTimeout(r, 100));+    if (i === 99) throw new Error('server did not start: ' + log);+  }+  const token = fs.readFileSync(path.join(dir, 'plan-agent-token'), 'utf8').trim();+  return { base, dir, data, port, proc: p, token, log: () => log };+}+function stop(s) { try { s.proc.kill('SIGTERM'); } catch (e) {} }++const getJ = async (s, u) => (await fetch(s.base + u)).json();+const getT = async (s, u) => (await fetch(s.base + u)).text();+async function post(s, u, body, headers = {}) {+  const r = await fetch(s.base + u, { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, headers),+    body: typeof body === 'string' ? body : JSON.stringify(body) });+  let j = null; const t = await r.text(); try { j = JSON.parse(t); } catch (e) {}+  return { status: r.status, j, t };+}+const byId = (d, id) => d.items.find(r => r.id === id);+const byName = (d, n) => d.items.find(r => r.name === n);++async function main() {+  // ── A: untagged plan with an OLD (v3) sidecar ──+  console.log('A. untagged plan, v3 sidecar');+  const v3 = { v: 3, tasks: [{ id: 'etch', s: '2026-10-06', e: '2026-10-09', owner: 'Drew' }],+    milestones: [], phases: [{ i: 1, start: '2026-10', end: '2027-01' }], order: [], deleted: [], added: [] };+  const A = await start('a', basePlan(false), v3, 8940);+  let ex = await getJ(A, '/export/json');+  let exText = await getT(A, '/export/json');+  ok(byId(ex, 'etch').owner === 'Drew' && byId(ex, 'etch').start === '2026-10-06', 'old v3 sidecar still loads (owner, dates)');+  ok(ex.phases[1].end === '2027-01', 'old v3 phase patch still applies');+  ok(!exText.includes('"tags"'), 'no tags key appears in /export/json of an untagged plan');+  const st = await getJ(A, '/state');+  ok(st.v === 4 && !JSON.stringify(st).includes('"tags"'), 'sidecar upgraded to v4 with no tags keys');+  const fileBefore = fs.readFileSync(A.data, 'utf8');+  ok((await getT(A, '/data')) === fileBefore, '/data of an untagged file is byte for byte the file');+  await getT(A, '/export/csv'); await getT(A, '/export/html');+  ok(fs.readFileSync(A.data, 'utf8') === fileBefore, 'exports leave the data file untouched');+  const etchUid = byId(ex, 'etch').uid, shipUid = byId(ex, 'ship').uid, msUid = byName(ex, 'Launch').uid;++  // Normalisation through /save (sidecar date-patch phase shape), via the agent token.+  const bad = await post(A, '/save', { v: 4, items: [], phases: [], order: [], deleted: [], added: [] }, { Authorization: 'Bearer deadbeef' });+  ok(bad.status === 401, '/save with a wrong agent token is 401', bad.status);+  const many = Array.from({ length: 15 }, (_, i) => 't' + i);+  const messy = ['  long-lead ', 'Long-Lead', '', '   ', 42, null, { a: 1 }, 'x'.repeat(40), 'LONG-LEAD'];+  const save1 = await post(A, '/save', {+    v: 4, baseRev: st.stateRev,+    items: [{ uid: etchUid, s: '2026-10-06', e: '2026-10-09', owner: 'Drew', tags: messy },+            { uid: shipUid, tags: many }, { uid: msUid, tags: 'not-a-list' }],+    phases: [{ i: 0, start: '2026-10', end: '2026-11', tags: [' Tech ', 'tech', 'Fab'] }, { i: 1, start: '2026-10', end: '2027-01', tags: { x: 1 } }],+    order: [], deleted: [], added: [],+  }, { Authorization: 'Bearer ' + A.token });+  ok(save1.status === 200, '/save with the agent token and baseRev is 200', save1);+  ex = await getJ(A, '/export/json');+  ok(eq(byId(ex, 'etch').tags, ['long-lead', 'x'.repeat(32)]), 'trim, case-insensitive dedupe (first spelling), non-strings dropped, 32-char cap', byId(ex, 'etch').tags);+  ok(eq(byId(ex, 'ship').tags, many.slice(0, 12)), 'list capped at 12', byId(ex, 'ship').tags);+  ok(!('tags' in byName(ex, 'Launch')), 'malformed row tags in /save dropped, not stored, not a 500');+  ok(eq(ex.phases[0].tags, ['Tech', 'Fab']), 'phase tags from a date-patch sidecar entry, normalised', ex.phases[0]);+  ok(!('tags' in ex.phases[1]), 'malformed phase tags dropped');+  const st2 = await getJ(A, '/state');+  const sEtch = st2.items.find(x => x.uid === etchUid), sMs = st2.items.find(x => x.uid === msUid);+  ok(eq(sEtch.tags, ['long-lead', 'x'.repeat(32)]) && !('tags' in sMs), 'sidecar stores normalised tags, drops malformed', st2.items);+  const rawSide = JSON.parse(fs.readFileSync(path.join(A.dir, 'plan-state.json'), 'utf8'));+  ok(eq(rawSide.phases[0].tags, ['Tech', 'Fab']) && !('tags' in rawSide.phases[1]), 'sidecar file on disk normalised');+  // A body that is broken in a different place entirely still does not 500.+  const garbage = await post(A, '/save', { v: 4, items: [null, 5, { uid: etchUid, tags: [[], 7] }], phases: [null, { i: 0, tags: null }] });+  ok(garbage.status === 200, 'odd /save body (null entries, nested junk) is not a 500', garbage.status);+  ex = await getJ(A, '/export/json');+  ok(!('tags' in byId(ex, 'etch')), 'a tag list of only non-strings normalises to [] (cleared)', byId(ex, 'etch'));+  // put tags back for the rest+  await post(A, '/save', { v: 4, items: [{ uid: etchUid, tags: ['long-lead', 'Etch'] }, { uid: msUid, tags: ['gate'] }],+    phases: [{ i: 0, start: '2026-10', end: '2026-11', tags: ['Tech'] }], order: [], deleted: [], added: [] });+  ex = await getJ(A, '/export/json');+  ok(eq(byId(ex, 'etch').tags, ['long-lead', 'Etch']) && eq(byName(ex, 'Launch').tags, ['gate']), 'task and milestone tags merged into /export/json');++  // HTML snapshot and CSV export+  const html = await getT(A, '/export/html');+  ok(html.includes('"tags":["long-lead","Etch"]') && html.includes('"tags":["Tech"]'), 'HTML snapshot inlines row and section tags');+  const csv = await getT(A, '/export/csv');+  const hdr = csv.split('\n')[0].split(',');+  ok(hdr.includes('tags') && hdr.indexOf('tags') === hdr.indexOf('deps') + 1, 'CSV has a tags column after deps', hdr);+  const etchLine = csv.split('\n').find(l => l.includes('Etch the cards'));+  ok(etchLine && etchLine.split(',')[hdr.indexOf('tags')] === 'long-lead;Etch', 'CSV tags joined with ;', etchLine);++  // Changelog: two new actions, via the agent token, rendered in md.+  const lg = await post(A, '/log', { events: [+    { action: 'tags', uid: etchUid, id: 'etch', name: 'Etch the cards', from: { tags: [] }, to: { tags: ['long-lead'] }, via: 'editor' },+    { action: 'tags', uid: etchUid, id: 'etch', name: 'Etch the cards', from: { tags: ['long-lead'] }, to: { tags: ['long-lead', 'Etch'] }, via: 'table-edit' },+    { action: 'tags', uid: msUid, name: 'Launch', from: { tags: ['gate'] }, to: { tags: [] }, via: 'editor' },+    { action: 'phase.tags', phase: 0, name: 'Tech · Fab', from: { tags: [] }, to: { tags: ['Tech'] }, via: 'setup' },+    { action: 'phase.tags', phase: 1, from: 'junk', to: { tags: 'Tech' } },+    { action: 'tags', to: { tags: [1, { a: 2 }, 'ok\nline'] }, extra: { weird: true } },+  ] }, { Authorization: 'Bearer ' + A.token, 'X-Ignore': '1' });+  ok(lg.status === 200 && lg.j.added === 6, '/log accepts tags and phase.tags with the agent token', lg);+  const mdRes = await fetch(A.base + '/changelog?format=md');+  const md = await mdRes.text();+  ok(mdRes.status === 200, 'changelog md renders 200 with malformed tag events');+  const need = [+    'adom-gantt agent tagged "Etch the cards" with long-lead (was none) (editor)',+    'adom-gantt agent tagged "Etch the cards" with long-lead, Etch (was long-lead) (table-edit)',+    'adom-gantt agent cleared the tags on "Launch" (was gate) (editor)',+    'adom-gantt agent tagged section "Tech · Fab" with Tech (was none) (setup)',+    'adom-gantt agent cleared the tags on section #2 (was none)',+    'adom-gantt agent tagged an item with ok line (was none)',+  ];+  need.forEach(n => ok(md.includes(n), 'md line: ' + n, md.split('\n').filter(l => /tag/.test(l))));+  const health = await getJ(A, '/health');+  ok(!health.integrity || health.integrity.ok !== false, 'hash chain still whole after the writes', health.integrity);++  // CSV round trip WITH the column: export -> import -> export+  const exBefore = await getJ(A, '/export/json');+  let imp = await post(A, '/import/csv', csv, { 'Content-Type': 'text/csv', Authorization: 'Bearer ' + A.token });+  ok(imp.status === 200, '/import/csv of our own export is 200', imp);+  let after = await getJ(A, '/export/json');+  ok(eq(byId(after, 'etch').tags, byId(exBefore, 'etch').tags) && eq(byName(after, 'Launch').tags, ['gate']) && !('tags' in byId(after, 'ship')),+    'CSV round trip keeps row tags; untagged rows get no key', after.items);+  ok(after.phases.every(p => !('tags' in p)), 'CSV carries no section tags');+  // CSV with messy tags+  const csv2 = 'name,start,end,phase,Tags\nA,2026-10-05,2026-10-09,P, a ; A ;b;;\nB,2026-10-05,2026-10-09,P,\n';+  imp = await post(A, '/import/csv', csv2, { 'Content-Type': 'text/csv' });+  after = await getJ(A, '/export/json');+  ok(imp.status === 200 && eq(byName(after, 'A').tags, ['a', 'b']) && !('tags' in byName(after, 'B')), 'CSV import splits on ; and normalises', after.items);+  // CSV without the column+  const csv3 = 'name,start,end,phase\nA,2026-10-05,2026-10-09,P\n';+  imp = await post(A, '/import/csv', csv3, { 'Content-Type': 'text/csv' });+  after = await getJ(A, '/export/json');+  ok(imp.status === 200 && !(await getT(A, '/export/json')).includes('"tags"'), 'CSV without a tags column imports as before');++  // JSON import normalises, and an untagged file round-trips unchanged+  const jp = basePlan(false);+  jp.phases[0].tags = [' Tech', 'TECH', 9];+  jp.phases[1].tags = [];+  jp.items[0].tags = ['a', 'A', 'b '];+  jp.items[1].tags = 'bad';+  imp = await post(A, '/import/json', jp);+  after = await getJ(A, '/export/json');+  ok(imp.status === 200 && eq(after.phases[0].tags, ['Tech']) && !('tags' in after.phases[1]), 'JSON import normalises phase tags; [] becomes no key', after.phases);+  ok(eq(byId(after, 'etch').tags, ['a', 'b']) && !('tags' in byId(after, 'ship')), 'JSON import normalises item tags; malformed dropped');+  const onDisk = JSON.parse(fs.readFileSync(A.data, 'utf8'));+  ok(!('tags' in onDisk.phases[1]) && !('tags' in onDisk.items[1]), 'imported data file stores no empty or malformed tags');+  // untagged JSON round trip+  const clean = basePlan(false);+  imp = await post(A, '/import/json', clean);+  const out1 = await getJ(A, '/export/json');+  imp = await post(A, '/import/json', out1);+  const out2 = await getJ(A, '/export/json');+  ok(eq(out1, out2) && !JSON.stringify(out2).includes('"tags"'), 'untagged JSON export -> import -> export is identical, no tags keys');+  // exported JSON round trip with tags+  const tp = basePlan(true);+  await post(A, '/import/json', tp);+  const t1 = await getJ(A, '/export/json');+  await post(A, '/import/json', t1);+  const t2 = await getJ(A, '/export/json');+  ok(eq(t1, t2) && eq(t2.phases[0].tags, ['Tech']) && eq(byId(t2, 'etch').tags, ['long-lead']), 'tagged JSON export -> import -> export is identical');++  // Jira Labels+  const jira = 'Summary,Issue key,Status,Created,Due Date,Labels\nFix it,GT-1,To Do,2026-10-01,2026-10-05,backend  Urgent urgent\nNo labels,GT-2,Done,2026-10-01,2026-10-03,\n';+  imp = await post(A, '/import/jira-csv', jira, { 'Content-Type': 'text/csv' });+  after = await getJ(A, '/export/json');+  ok(imp.status === 200 && eq(byName(after, 'Fix it').tags, ['backend', 'Urgent']) && !('tags' in byName(after, 'No labels')), 'Jira Labels map to tags', after.items);+  // MS Project: no tags+  const xml = '<Project><Tasks><Task><UID>1</UID><Name>X</Name><Start>2026-10-01T08:00:00</Start><Finish>2026-10-05T17:00:00</Finish></Task></Tasks></Project>';+  imp = await post(A, '/import/msproject-xml', xml, { 'Content-Type': 'application/xml' });+  ok(imp.status === 200 && !(await getT(A, '/export/json')).includes('"tags"'), 'MS Project import carries no tags', imp);+  stop(A);++  // ── B: tagged data file, full-shape sidecar written by a client that predates tags ──+  console.log('B. tagged data file, full-shape sidecar');+  const B = await start('b', basePlan(true), null, 8941);+  const d0 = await getJ(B, '/data');+  ok(eq(d0.phases[0].tags, ['Tech']), '/data normalises a hand-edited tag list', d0.phases[0]);+  ex = await getJ(B, '/export/json');+  const eU = byId(ex, 'etch').uid;+  // old client: full phase list with names, no tags keys; item override without tags+  let r = await post(B, '/save', { v: 4, items: [{ uid: eU, owner: 'Drew' }],+    phases: ex.phases.slice().reverse().map((p, i) => ({ i, name: p.name, start: p.start, end: p.end, uid: p.uid })), order: [], deleted: [], added: [] });+  ex = await getJ(B, '/export/json');+  ok(r.status === 200 && ex.phases[0].name === 'Ops' && eq(ex.phases[1].tags, ['Tech']) && !('tags' in ex.phases[0]),+    'absent tags in a full-shape (reordered) sidecar: the data file tags stand, by section', ex.phases);+  ok(eq(byId(ex, 'etch').tags, ['long-lead']) && byId(ex, 'etch').owner === 'Drew', 'absent tags on an item override: data file tags stand');+  // explicit [] clears+  r = await post(B, '/save', { v: 4, items: [{ uid: eU, tags: [] }],+    phases: ex.phases.map((p, i) => ({ i, name: p.name, start: p.start, end: p.end, uid: p.uid, tags: [] })), order: [], deleted: [], added: [] });+  ex = await getJ(B, '/export/json');+  ok(!('tags' in byId(ex, 'etch')) && ex.phases.every(p => !('tags' in p)), 'explicit [] in the sidecar clears the data file tags', ex);+  ok(eq(byName(ex, 'Launch').tags, ['gate']), 'untouched milestone keeps its data file tags');+  // a created row (sidecar `added`) with messy tags+  r = await post(B, '/save', { v: 4, items: [], phases: [], order: [], deleted: [],+    added: [{ type: 'task', uid: '00900', id: 'new', name: 'New one', start: '2026-10-05', end: '2026-10-06', phase: 0, tags: [' n ', 'N', ''] },+            { type: 'task', uid: '00901', id: 'new2', name: 'New two', start: '2026-10-05', end: '2026-10-06', phase: 0, tags: [] }] });+  ex = await getJ(B, '/export/json');+  ok(eq(byId(ex, 'new').tags, ['n']) && !('tags' in byId(ex, 'new2')), 'created rows carry normalised tags; empty means no key', [byId(ex, 'new'), byId(ex, 'new2')]);+  // compact bakes the merged tags into the data file (the last /save carried no+  // overrides, since a save is whole-state, so the data file's own tags stand again)+  r = await post(B, '/compact', {}, { Authorization: 'Bearer ' + B.token });+  const baked = JSON.parse(fs.readFileSync(B.data, 'utf8'));+  ok(r.status === 200 && eq(byId(baked, 'new').tags, ['n']) && eq(byName(baked, 'Launch').tags, ['gate']) && eq(byId(baked, 'etch').tags, ['long-lead']) && eq(baked.phases[0].tags, ['Tech']),+    '/compact bakes merged tags into the data file', r);+  stop(B);++  // ── C: a full sidecar section list LONGER than the data file's (0.58.0 review, V-A) ──+  // Sections added in Setup live only in the sidecar. A row saved into one of them, or into+  // a data-file section moved down past them, has a phase index beyond the data file's+  // list. Until 0.58.0 the merge checked that index against the data file's list, failed+  // it, and left the row in whatever section held its OLD index.+  console.log('C. sidecar with more sections than the data file');+  const C = await start('c', basePlan(false), null, 8940);+  ex = await getJ(C, '/export/json');+  const cE = byId(ex, 'etch').uid, cS = byId(ex, 'ship').uid, cM = byName(ex, 'Launch').uid;+  // Order: Added, Ops, Tech · Fab. Etch (data-file phase 0) is saved at index 2, past the+  // data file's two sections; Ship and Launch (data-file phase 1) at index 1.+  r = await post(C, '/save', { v: 4, items: [{ uid: cE, phase: 2 }, { uid: cS, phase: 1 }, { uid: cM, phase: 1 }],+    phases: [{ i: 0, name: 'Added', start: '', end: '' }, { i: 1, name: 'Ops', start: '2026-10', end: '2026-12' }, { i: 2, name: 'Tech · Fab', start: '2026-10', end: '2026-11' }],+    order: [], deleted: [], added: [] });+  ex = await getJ(C, '/export/json');+  const secOf = n => ex.phases[byName(ex, n).phase]?.name;+  ok(r.status === 200 && secOf('Etch the cards') === 'Tech · Fab' && secOf('Ship') === 'Ops' && secOf('Launch') === 'Ops',+    'a row saved into a section past the data file\'s list stays in it', ex.items.map(x => [x.name, x.phase]));+  // An index past the SIDECAR's list is still refused, and the row keeps its data-file one.+  r = await post(C, '/save', { v: 4, items: [{ uid: cE, phase: 7 }, { uid: cS, phase: -1 }],+    phases: [{ i: 0, name: 'Added', start: '', end: '' }, { i: 1, name: 'Ops', start: '2026-10', end: '2026-12' }, { i: 2, name: 'Tech · Fab', start: '2026-10', end: '2026-11' }],+    order: [], deleted: [], added: [] });+  ex = await getJ(C, '/export/json');+  ok(byId(ex, 'etch').phase === 0 && byId(ex, 'ship').phase === 1, 'a phase index outside the sidecar\'s list is ignored', ex.items.map(x => [x.name, x.phase]));+  stop(C);+}++try { await main(); }+catch (e) { fail++; console.log('  FAIL threw: ' + (e && e.stack || e)); }+finally {+  procs.forEach(p => { try { p.kill('SIGTERM'); } catch (e) {} });+  try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (e) {}+}+console.log(`\n${pass} passed, ${fail} failed`);+process.exit(fail ? 1 : 0);
test/ui_test.mjs+302−41
@@ -31,6 +31,7 @@ import { spawn } from 'node:child_process'; import { mkdtempSync, rmSync, writeFileSync, readFileSync, readdirSync, mkdirSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import net from 'node:net';+import http from 'node:http'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -234,7 +235,11 @@ async function openPage(browser, url, { w = 1440, h = 900, mobile = false, today     const u = req.url(), why = req.failure()?.errorText || '';     // ERR_ABORTED is the browser cancelling its own request (a reload or a navigation     // while it was in flight), not the server failing it.-    if (own(u) && why !== 'net::ERR_ABORTED' && !allowed(u, 0)) pageErrors.push(`[${tag}] request failed ${req.method()} ${u.replace(/^https?:\/\/[^/]+/, '')} (${why})`);+    // ERR_NETWORK_CHANGED is Chrome noticing the HOST's network configuration change+    // under it (a VPN, a container network restart): the request never reached the+    // server, and the app cannot have caused it. Seen twice on 2026-09-30, both times+    // during a container-side restart, both times on a run that was otherwise green.+    if (own(u) && why !== 'net::ERR_ABORTED' && why !== 'net::ERR_NETWORK_CHANGED' && !allowed(u, 0)) pageErrors.push(`[${tag}] request failed ${req.method()} ${u.replace(/^https?:\/\/[^/]+/, '')} (${why})`);   });   await page.setViewport({ width: w, height: h, isMobile: mobile, hasTouch: mobile, deviceScaleFactor: 1 });   // Puppeteer's own emulateMediaFeatures refuses any-hover/any-pointer, so straight to CDP.@@ -608,13 +613,47 @@ async function main() {       check('the snapshot did not overwrite the reader\'s other stored prefs', stored.granularity === 'day' && stored.theme === 'light' && stored.fs === 1.2, JSON.stringify(stored));       // A stored value that parses to an ARRAY used to swallow the snapshot's write.       await page.evaluate(() => localStorage.setItem('adom-gantt:prefs', '[1,2,3]'));-      await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+      // Open without the fragment the A+ above wrote, since this case is about storage.+      await page.goto(snapUrl, { waitUntil: 'networkidle0' }); await page.reload({ waitUntil: 'networkidle0' }); await settle(page);       await page.click('#fsUp'); await settle(page);       await page.reload({ waitUntil: 'networkidle0' }); await settle(page);       check('a stored array does not swallow the size (110% after A+ from 100%)', await fsPct(page) === '110%', await fsPct(page));       const snapR = await readouts(page);       check('the snapshot shows the same readouts', snapR.dur === '16w 4d' && snapR.left === '15w 3d' && snapR.start === '21-Sep-2026', JSON.stringify(snapR));       await page.close();++      // Cause 3 (0.58.0): the wiki serves an uploaded snapshot under a CSP sandbox with+      // no allow-same-origin. The page gets an opaque origin, storage works for the life+      // of the page and every reload starts empty, which is what Drew saw on a 0.57.1+      // snapshot at wiki.adom.inc. Same header here, on a throwaway static server.+      section = 'text size (sandboxed snapshot)';+      const sandboxSrv = http.createServer((req, res) => {+        res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store',+          'content-security-policy': "sandbox allow-scripts allow-downloads allow-popups; default-src 'none'; script-src 'unsafe-inline' https:; style-src 'unsafe-inline' https:; img-src data: https:; font-src data: https:; connect-src 'self'" });+        res.end(html);+      });+      const sandboxPort = await freePort();+      await new Promise(r => sandboxSrv.listen(sandboxPort, '127.0.0.1', r));+      try {+        const sbUrl = `http://127.0.0.1:${sandboxPort}/render/gantt/index.html`;+        page = await openPage(browser, sbUrl);+        check('sandboxed snapshot opens at 100%', await fsPct(page) === '100%', await fsPct(page));+        await page.click('#fsUp'); await settle(page); await page.click('#fsUp'); await settle(page);+        check('A+ twice reads 120% and the URL fragment carries it', await fsPct(page) === '120%' && await page.evaluate(() => location.hash) === '#fs=1.2', await page.evaluate(() => location.hash));+        const storedInSandbox = await page.evaluate(() => { try { return localStorage.getItem('adom-gantt:prefs'); } catch (e) { return 'THROWS ' + e.name; } });+        await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+        const storedAfter = await page.evaluate(() => { try { return localStorage.getItem('adom-gantt:prefs'); } catch (e) { return 'THROWS ' + e.name; } });+        check('the sandbox really does deny or forget storage across a reload (the test is honest)', storedAfter === null || /THROWS/.test(storedAfter), `${storedInSandbox} -> ${storedAfter}`);+        check('and the text size still survives the refresh (120%)', await fsPct(page) === '120%', await fsPct(page));+        await page.click('#fsPct'); await settle(page);+        check('reset to 100% clears the fragment', await page.evaluate(() => location.hash) === '#fs=1', await page.evaluate(() => location.hash));+        // A hash-only goto is a same-document navigation, so reload to load the page afresh.+        await page.goto(`${sbUrl}#fs=1.35`); await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+        check('a shared link with #fs=1.35 opens at 135%', await fsPct(page) === '135%', await fsPct(page));+        await page.goto(`${sbUrl}#fs=bogus`); await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+        check('a bad fragment value falls back to 100%', await fsPct(page) === '100%', await fsPct(page));+        await page.close();+      } finally { await new Promise(r => sandboxSrv.close(r)); }     }      if (wantAny('collapse all')) {@@ -794,6 +833,23 @@ async function main() {         await view(page, 'table');         await assertNoSidewaysScroll(page, 'table');         await shot(page, `viewport-${vp.w}-table`);+        // The other two lenses (0.58.0 review): Analytics, and Setup with its 20 sections,+        // plain and with each kind of row editor open, since an open editor is the widest+        // thing the page can show. Nothing inside the page may run past the window either.+        await view(page, 'analytics');+        await assertNoSidewaysScroll(page, 'analytics');+        await view(page, 'setup');+        const setupOver = () => page.evaluate(() => [...document.querySelectorAll('#setupView *')].filter(e => window.__uiVis(e))+          .map(e => ({ e, r: e.getBoundingClientRect() })).filter(x => x.r.right > innerWidth + 0.5 || x.r.left < -0.5)+          .map(x => (x.e.className || x.e.tagName) + ' @' + Math.round(x.r.right)).slice(0, 4));+        check('Setup lists all 20 sections', await page.$$eval('#setupView .sec-row', e => e.length) === 20);+        for (const [what, open] of [['setup', null], ['setup, rename open', 'edit'], ['setup, remove open', 'del']]) {+          if (open) { await page.click(`.sec-row[data-i="1"] .sec-btn[data-a="${open}"]`); await settle(page, 150); }+          await assertNoSidewaysScroll(page, what);+          const o = await setupOver();+          check(`${what}: nothing on the page runs past the window`, o.length === 0, o.join(' | '));+          await shot(page, `viewport-${vp.w}-${what.replace(/[ ,]+/g, '-')}`);+        }         await page.close();       }     }@@ -872,15 +928,17 @@ async function main() { // The header controls each lens must show: PRESENT, visible (strictly: no opacity:0, // no visibility:hidden, a non-zero box) and wholly inside the window, at every swept // width. A control a lens hides on purpose is listed in HDR_HIDDEN and must be hidden.-const HDR_COMMON = ['.brand', '.vt-btn[data-view="gantt"]', '.vt-btn[data-view="table"]', '.vt-btn[data-view="analytics"]',-  '#spanDur', '#spanLeft', '#btnTheme', '#btnSections', '#btnImport', '#btnExport', '#btnHelp', '#btnNew', '#fsDown', '#fsPct', '#fsUp'];+// 0.58.0: the Sections button left the header for the Setup lens, which is the fourth tab.+const HDR_COMMON = ['.brand', '.vt-btn[data-view="gantt"]', '.vt-btn[data-view="table"]', '.vt-btn[data-view="analytics"]', '.vt-btn[data-view="setup"]',+  '#spanDur', '#spanLeft', '#btnTheme', '#btnImport', '#btnExport', '#btnHelp', '#btnNew', '#fsDown', '#fsPct', '#fsUp']; const HDR_CHART_ONLY = ['#startBtn', '#grainSel', '#btnFit', '#btnDeps', '#btnMsStyle']; const HDR_EXPECT = {   gantt: [...HDR_COMMON, ...HDR_CHART_ONLY, '#btnAuto', '#btnCrit'],   table: [...HDR_COMMON, '#btnAuto', '#btnCrit'],   analytics: [...HDR_COMMON],+  setup: [...HDR_COMMON], };-const HDR_HIDDEN = { gantt: [], table: HDR_CHART_ONLY, analytics: [...HDR_CHART_ONLY, '#btnAuto', '#btnCrit'] };+const HDR_HIDDEN = { gantt: [], table: HDR_CHART_ONLY, analytics: [...HDR_CHART_ONLY, '#btnAuto', '#btnCrit'], setup: [...HDR_CHART_ONLY, '#btnAuto', '#btnCrit'] }; // Everything that could hide the readout: an ancestor that clips, the window edge, or // the text itself being cut (a span whose content is wider than its box). async function headerProbe(page, lens = 'gantt') {@@ -925,11 +983,11 @@ async function headerSweep(browser, targets) {   const widths = new Set([393, 1181, 1210, 1250, 1280, 1330, 1366, 1392, 1440, 1500, 1536, 1620, 1680, 1745, 1830]);   for (let w = 360; w <= 2000; w += step) widths.add(w);   const W = [...widths].sort((a, b) => a - b);-  // Every target in the chart lens at 100% and 150%; the other two lenses (fewer+  // Every target in the chart lens at 100% and 150%; the other three lenses (fewer   // controls, so less at stake) on the first target at 100% and 150%.   const runs = [];   for (const [label, url] of targets) for (const fs of [1, 1.5]) runs.push([label, url, fs, 'gantt']);-  for (const lens of ['table', 'analytics']) for (const fs of [1, 1.5]) runs.push([targets[0][0], targets[0][1], fs, lens]);+  for (const lens of ['table', 'analytics', 'setup']) for (const fs of [1, 1.5]) runs.push([targets[0][0], targets[0][1], fs, lens]);   let selfTested = false;   for (const [label, url, fs, lens] of runs) {     const page = await openPage(browser, url, { w: 1440, h: 900 });@@ -975,7 +1033,7 @@ async function headerSweep(browser, targets) { // member of the family is an orphan until it is declared. `within` scopes an entry to // an ancestor id. const MANIFEST = [-  { sel: '.vt-btn', label: 'lens switch', safe: true, key: c => c.ds.view, pin: ['gantt', 'table', 'analytics'] },+  { sel: '.vt-btn', label: 'lens switch', safe: true, key: c => c.ds.view, pin: ['gantt', 'table', 'analytics', 'setup'] },   { sel: '#startBtn', label: 'start date', safe: true },   { sel: '#grainSel', label: 'grain picker', terminal: true, by: 'dates' },   { sel: '#btnFit', label: 'Fit', safe: true },@@ -984,7 +1042,6 @@ const MANIFEST = [   { sel: '#btnCrit', label: 'critical path', safe: true, twice: true },   { sel: '#btnMsStyle', label: 'milestone style', safe: true },   { sel: '#btnTheme', label: 'theme', safe: true },-  { sel: '#btnSections', label: 'Sections', safe: true },   { sel: '#btnImport', label: 'Import menu', safe: true },   { sel: '#btnExport', label: 'Export menu', safe: true },   { sel: '#btnHelp', label: 'help menu', safe: true },@@ -1008,7 +1065,16 @@ const MANIFEST = [   { sel: '.sec-btn', label: 'section row actions', terminal: true, by: 'sections', key: c => c.ds.a, pin: ['up', 'down', 'edit', 'del'] },   { sel: '#secNew', label: 'new section name', terminal: true, by: 'sections' },   { sel: '#secAdd', label: 'add section', terminal: true, by: 'sections' },-  { sel: '#secDone', label: 'close Sections', safe: true },+  // The Setup page's inline row editors (0.58.0: they were never opened by the gate while+  // they lived in the modal, so they were not enumerated; on the page they are states).+  { sel: '.sec-nm', label: 'section rename: name', terminal: true, by: 'sections' },+  { sel: '.sec-ld', label: 'section rename: aside', terminal: true, by: 'sections' },+  { sel: '.sec-ok', label: 'section rename: Save', terminal: true, by: 'sections' },+  { sel: '.sec-chip', label: 'section colour palette', terminal: true, by: 'sections', key: c => c.title, pin: ['Theme', 'Teal', 'Purple', 'Blue', 'Green', 'Orange', 'Red', 'Slate'] },+  { sel: '.sec-hex', label: 'section colour: hex', terminal: true, by: 'sections' },+  { sel: '.sec-dest', label: 'section remove: destination', terminal: true, by: 'sections' },+  { sel: '.sec-go', label: 'section remove: confirm', terminal: true, by: 'sections' },+  { sel: '.sec-no', label: 'section remove: Cancel', safe: true },   { sel: '#ncType', label: 'new: type', terminal: true, by: 'new item fields' },   { sel: '#ncPhase', label: 'new: section', terminal: true, by: 'new item fields' },   { sel: '#ncName', label: 'new: name', terminal: true, by: 'live: create' },@@ -1167,7 +1233,10 @@ async function coverageGate(browser, url) {     ['export menu', async () => { await page.click('#btnExport'); }],     ['import menu', async () => { await page.click('#btnImport'); }],     ['help menu', async () => { await page.click('#btnHelp'); }],-    ['sections', async () => { await page.click('#btnSections'); }],+    ['setup', async () => { await view(page, 'setup'); }],+    ['setup: rename', async () => { await view(page, 'setup'); await page.click('.sec-row[data-i="1"] .sec-btn[data-a="edit"]'); }],+    ['setup: colour', async () => { await view(page, 'setup'); await page.click('.sec-row[data-i="1"] .sec-sw'); }],+    ['setup: remove', async () => { await view(page, 'setup'); await page.click('.sec-row[data-i="1"] .sec-btn[data-a="del"]'); }],     ['new item', async () => { await page.click('#btnNew'); }],     ['bar editor', async () => { await view(page, 'gantt'); await clickBar(page, 'finale'); }],     ['milestone editor', async () => { await view(page, 'gantt'); await clickMilestone(page, kick.uid); }],@@ -1523,41 +1592,233 @@ async function menuFlows(browser, S) {   await ip.close(); } +// The Setup lens (0.58.0). Until 0.57.1 this flow drove the header's Sections modal; the+// same operations now live on a page, so it drives the page and asserts the effects+// against the plan the SERVER saved.+//+// Hardened after the 0.58.0 review, which found three data bugs this flow could not see:+// its seed had rows in only the first two sections and no uids, it never reloaded after a+// structural change, and it never undid one. So now: a task in EVERY section and a+// milestone in two, no phase uids (like the real instaPCB plan), and after every+// structural step (add, reorder, remove, and each of their undos and redos) the WHOLE+// section list and EVERY row's section are compared with /export/json by name, and the+// page is checked to have repainted to match. async function sectionsFlow(browser) {   section = 'sections';+  const T = (id, name, ph) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph });   const X = await startServer({ project: { title: 'Sections Test', appName: 'sections-test' },-    phases: [{ name: 'Alpha', start: '2026-09', end: '2026-12' }, { name: 'Beta', start: '2026-09', end: '2026-12' }, { name: 'Gamma', start: '2026-09', end: '2026-12' }],-    items: [{ type: 'task', id: 'a', name: 'Alpha work', start: '2026-10-05', end: '2026-10-09', phase: 0, uid: '00001' },-      { type: 'task', id: 'b', name: 'Beta work', start: '2026-10-05', end: '2026-10-09', phase: 1, uid: '00002' }] }, 'sections');-  const page = await openPage(browser, X.url);-  const names = async () => (await savedPlan(X.url)).phases.map(p => p.name);-  const rowBtn = (i, a) => page.click(`.sec-row[data-i="${i}"] .sec-btn[data-a="${a}"]`);-  await page.click('#btnSections'); await settle(page, 150);-  await page.type('#secNew', 'Delta'); await page.click('#secAdd'); await settle(page, 300);-  check('Add section appends it', JSON.stringify(await names()) === '["Alpha","Beta","Gamma","Delta"]', JSON.stringify(await names()));-  await rowBtn(0, 'down'); await settle(page, 200);-  let nm = await names(); let pl = await exportPlan(X.url);-  check('Move down swaps a section with the next, and its rows go with it', JSON.stringify(nm) === '["Beta","Alpha","Gamma","Delta"]' && rowOf(pl, r => r.id === 'a').phase === 1 && rowOf(pl, r => r.id === 'b').phase === 0, JSON.stringify(nm));-  await rowBtn(1, 'up'); await settle(page, 200);-  check('Move up swaps it back', JSON.stringify(await names()) === '["Alpha","Beta","Gamma","Delta"]');-  await rowBtn(2, 'edit'); await settle(page, 100);-  await page.$eval('.sec-row[data-i="2"] .sec-nm', e => { e.value = 'Gamma renamed'; });-  await page.click('.sec-row[data-i="2"] .sec-ok'); await settle(page, 200);-  check('Edit renames a section', (await names())[2] === 'Gamma renamed');-  await page.click('.sec-row[data-i="2"] .sec-sw'); await settle(page, 100);-  const hex = await page.$eval('.sec-row[data-i="2"] .sec-chip[data-hex^="#"]', b => { b.click(); return b.dataset.hex; }); await settle(page, 200);-  check('the colour swatch recolours the section', (await savedPlan(X.url)).phases[2].color === hex, `${hex} vs ${(await exportPlan(X.url)).phases[2].color}`);-  await rowBtn(3, 'del'); await settle(page, 100);-  await page.click('.sec-row[data-i="3"] .sec-go'); await settle(page, 200);-  check('Remove deletes an empty section', JSON.stringify(await names()) === '["Alpha","Beta","Gamma renamed"]', JSON.stringify(await names()));-  await rowBtn(1, 'del'); await settle(page, 100);-  await page.select('.sec-row[data-i="1"] .sec-dest', '0');-  await page.click('.sec-row[data-i="1"] .sec-go'); await settle(page, 200);+    phases: [{ name: 'Alpha', start: '2026-09', end: '2026-12' }, { name: 'Beta', start: '2026-09', end: '2026-12' },+      { name: 'Gamma', start: '2026-09', end: '2026-12' }, { name: 'Delta', start: '2026-09', end: '2026-12' }],+    items: [T('a', 'Alpha work', 0), { type: 'milestone', name: 'Alpha gate', date: '2026-11-02', phase: 0 },+      T('b', 'Beta work', 1), T('g', 'Gamma work', 2), { type: 'milestone', name: 'Gamma gate', date: '2026-11-09', phase: 2 },+      T('d', 'Delta work', 3)] }, 'sections');+  let page = await openPage(browser, X.url);+  // Which section each row is in, by NAME, from a saved plan. Names are unique in this seed.+  const assign = pl => Object.fromEntries((pl.items || []).filter(r => r.type !== 'group').map(r => [r.name, pl.phases[r.phase]?.name ?? `#${r.phase}?`]));+  // The rows every section should hold, which only a remove may change.+  let HOME = { 'Alpha work': 'Alpha', 'Alpha gate': 'Alpha', 'Beta work': 'Beta', 'Gamma work': 'Gamma', 'Gamma gate': 'Gamma', 'Delta work': 'Delta' };+  const listNames = (pg = page) => pg.$$eval('#setupView .sec-row .sec-name', els => els.map(e => e.firstChild.textContent));+  const counts = (pg = page) => pg.$$eval('#setupView .sec-row .sec-count', els => els.map(e => parseInt(e.textContent, 10)));+  const want = (names, home) => names.map(n => Object.values(home).filter(v => v === n).length);+  // The whole state, three ways: the saved section list, every row's saved section, and+  // the page (names and per-section counts) repainted to the same.+  const expectState = async (what, names, home = HOME) => {+    const pl = await savedPlan(X.url);+    const got = pl.phases.map(p => p.name), a = assign(pl);+    const bad = Object.keys(home).filter(k => a[k] !== home[k]).map(k => `${k}: ${a[k]} (want ${home[k]})`);+    check(`${what}: sections saved in order ${names.join(',')}`, JSON.stringify(got) === JSON.stringify(names), JSON.stringify(got));+    check(`${what}: every row is saved in its own section`, bad.length === 0 && Object.keys(a).length === Object.keys(home).length, bad.join('; ') || JSON.stringify(a));+    const ln = await listNames(), lc = await counts();+    check(`${what}: the page shows the same list and counts`, JSON.stringify(ln) === JSON.stringify(names) && JSON.stringify(lc) === JSON.stringify(want(names, home)), `${JSON.stringify(ln)} ${JSON.stringify(lc)}`);+  };+  const idx = async name => (await listNames()).indexOf(name);+  const rowBtn = async (name, a) => page.click(`.sec-row[data-i="${await idx(name)}"] .sec-btn[data-a="${a}"]`);+  const redo = async (pg = page) => { await pg.keyboard.down('Control'); await pg.keyboard.down('Shift'); await pg.keyboard.press('KeyZ'); await pg.keyboard.up('Shift'); await pg.keyboard.up('Control'); await settle(pg, 250); };+  const axisText = pg => pg.evaluate(() => [...document.querySelectorAll('.q-row > *, .m-row > *')].map(c => c.textContent).join(' '));++  check('the header has no Sections button any more', !(await page.$('#btnSections')));+  await view(page, 'setup');+  check('Setup is a page: the list is on screen and no modal is open', await page.$eval('#setupView', e => window.__uiVis(e)) && !(await page.$('.sec-back')));+  check('Setup hides the chart and the legend, as Analytics does', await page.evaluate(() => !window.__uiVis(document.querySelector('.chart-wrap')) && !window.__uiVis(document.querySelector('.legend'))));+  const rowInfo = await page.$eval('.sec-row[data-i="0"]', r => ({ n: r.querySelector('.sec-count').textContent, d: r.querySelector('.sec-dates').textContent }));+  check('each row shows its row count and the dates its rows span', rowInfo.n === '2 rows' && rowInfo.d === '05-Oct-2026 to 02-Nov-2026', JSON.stringify(rowInfo));+  let N = ['Alpha', 'Beta', 'Gamma', 'Delta'];+  await expectState('on load', N);++  // ── Add, and its guards ──+  await page.type('#secNew', 'Echo'); await page.click('#secAdd'); await settle(page, 300);+  N = [...N, 'Echo'];+  await expectState('Add section appends it', N);+  check('a section with no rows says it has no dates', await page.$eval(`.sec-row[data-i="4"] .sec-dates`, e => e.textContent) === 'no dates');+  // D-3: Enter pressed five times while the id is being fetched makes ONE section.+  await page.type('#secNew', 'Foxtrot');+  await page.$eval('#secNew', e => { for (let i = 0; i < 5; i++) e.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });+  await settle(page, 400);+  N = [...N, 'Foxtrot'];+  await expectState('Enter pressed five times at once adds one section', N);+  // D-2: a name another section already has (any case, any spacing) is refused, and says so.+  await page.type('#secNew', '  beta '); await page.click('#secAdd'); await settle(page, 300);+  check('Add refuses a name another section has, and says why', JSON.stringify(await listNames()) === JSON.stringify(N)+    && await page.$eval('#setupView .sec-err', e => window.__uiVis(e) && /already a section called/.test(e.textContent)));+  // Out of the box, so the next Ctrl+Z is the page's and not the text field's own undo.+  await page.$eval('#secNew', e => { e.value = ''; e.dispatchEvent(new Event('input')); e.blur(); });+  // Undo and redo of an add.+  await undo(page);+  await expectState('Ctrl+Z takes the added section back', N.slice(0, 5));+  await redo();+  await expectState('redo adds it again', N);+  // V-B: a section made here has no dates of its own, and a reload must not read that as 1970.+  await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+  await view(page, 'gantt');+  let ax = await axisText(page);+  check('after adding sections and a reload the chart axis is the plan\'s own years, not 1970', ax.includes('2026') && !/19(69|70)/.test(ax), ax.slice(0, 120));+  await view(page, 'setup');++  // ── Reorder ──+  await rowBtn('Alpha', 'down'); await settle(page, 200);+  await expectState('Move down swaps a section with the next and its rows go with it', N = ['Beta', 'Alpha', 'Gamma', 'Delta', 'Echo', 'Foxtrot']);+  await rowBtn('Alpha', 'up'); await settle(page, 200);+  await expectState('Move up swaps it back', N = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Echo', 'Foxtrot']);+  await undo(page);+  await expectState('Ctrl+Z undoes the move up', ['Beta', 'Alpha', 'Gamma', 'Delta', 'Echo', 'Foxtrot']);+  await redo();+  await expectState('redo moves it up again', N);+  // V-A: a populated section moved PAST sections added since the data file was written.+  // Its rows' saved indices are beyond the data file's list, and until the review a reload+  // checked them against that list, kept the old index and put them in another section.+  await rowBtn('Delta', 'down'); await rowBtn('Delta', 'down'); await settle(page, 200);+  await expectState('Delta moved down past the two new sections', N = ['Alpha', 'Beta', 'Gamma', 'Echo', 'Foxtrot', 'Delta']);+  await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+  await expectState('after a reload every row is still in its own section', N);+  const snapHtml0 = await (await fetch(X.url + 'export/html')).text();++  // ── Rename and aside: one Save, one undo ──+  await rowBtn('Gamma', 'edit'); await settle(page, 100);+  let gi = await idx('Gamma');+  await page.$eval(`.sec-row[data-i="${gi}"] .sec-nm`, e => { e.value = 'Gamma renamed'; });+  await page.$eval(`.sec-row[data-i="${gi}"] .sec-ld`, e => { e.value = 'the long pole'; });+  await page.click(`.sec-row[data-i="${gi}"] .sec-ok`); await settle(page, 200);+  let pl = await savedPlan(X.url);+  check('Edit renames a section and sets its aside', pl.phases[gi].name === 'Gamma renamed' && pl.phases[gi].lead === 'the long pole', JSON.stringify(pl.phases[gi]));+  HOME = Object.fromEntries(Object.entries(HOME).map(([k, v]) => [k, v === 'Gamma' ? 'Gamma renamed' : v]));+  N = N.map(n => n === 'Gamma' ? 'Gamma renamed' : n);+  await undo(page);   pl = await savedPlan(X.url);-  check('Remove on a populated section moves its rows to the chosen one first', JSON.stringify(pl.phases.map(p => p.name)) === '["Alpha","Gamma renamed"]' && rowOf(pl, r => r.id === 'b').phase === 0, JSON.stringify(pl.phases.map(p => p.name)));-  await page.click('#secDone'); await settle(page, 150);-  check('Done closes the Sections panel', !(await page.$('.sec-back')));+  check('ONE Ctrl+Z takes back both the name and the aside', pl.phases[gi].name === 'Gamma' && !pl.phases[gi].lead, JSON.stringify(pl.phases[gi]));+  await redo();+  pl = await savedPlan(X.url);+  check('one redo puts both back', pl.phases[gi].name === 'Gamma renamed' && pl.phases[gi].lead === 'the long pole', JSON.stringify(pl.phases[gi]));+  await rowBtn('Gamma renamed', 'edit'); await settle(page, 100);+  await page.$eval(`.sec-row[data-i="${gi}"] .sec-nm`, e => { e.value = 'ALPHA'; });+  await page.click(`.sec-row[data-i="${gi}"] .sec-ok`); await settle(page, 200);+  check('rename refuses a name another section has, and says why', await page.$eval(`.sec-row[data-i="${gi}"] .sec-msg`, e => /already a section called/.test(e.textContent)).catch(() => false)+    && (await savedPlan(X.url)).phases[gi].name === 'Gamma renamed');+  await page.keyboard.press('Escape'); await settle(page, 150);+  check('Escape in the rename field cancels it', !(await page.$('.sec-row.sec-editing')) && (await listNames())[gi] === 'Gamma renamed');+  await rowBtn('Alpha', 'del'); await settle(page, 100);+  await page.focus('.sec-row.sec-editing .sec-go'); await page.keyboard.press('Escape'); await settle(page, 150);+  check('Escape with the focus on a row button cancels that row too', !(await page.$('.sec-row.sec-editing')));++  // ── Colour ──+  await rowBtn('Gamma renamed', 'edit'); await page.click(`.sec-row[data-i="${gi}"] .sec-sw`); await settle(page, 100);+  check('opening a second row editor closes the first', await page.$$eval('.sec-row.sec-editing', e => e.length) === 1 && !!(await page.$('.sec-row.sec-editing .sec-pal')));+  const hex = await page.$eval(`.sec-row[data-i="${gi}"] .sec-chip[data-hex^="#"]`, b => { b.click(); return b.dataset.hex; }); await settle(page, 200);+  check('the colour swatch recolours the section', (await savedPlan(X.url)).phases[gi].color === hex);+  await page.click(`.sec-row[data-i="${gi}"] .sec-sw`); await settle(page, 100);+  await page.$eval(`.sec-row[data-i="${gi}"] .sec-hex`, e => { e.value = ''; });+  await page.type(`.sec-row[data-i="${gi}"] .sec-hex`, 'zzzzzz'); await page.keyboard.press('Enter'); await settle(page, 150);+  check('a colour that is not one is refused with a message saying why', await page.$eval(`.sec-row[data-i="${gi}"] .sec-msg`, e => /not a colour/.test(e.textContent)).catch(() => false)+    && (await savedPlan(X.url)).phases[gi].color === hex);+  await page.$eval(`.sec-row[data-i="${gi}"] .sec-hex`, e => { e.value = ''; });+  await page.type(`.sec-row[data-i="${gi}"] .sec-hex`, '#ABC'); await page.keyboard.press('Enter'); await settle(page, 200);+  check('#ABC is stored as #aabbcc', (await savedPlan(X.url)).phases[gi].color === '#aabbcc', (await exportPlan(X.url)).phases[gi].color);+  const swatchIs = h => page.$eval(`.sec-row[data-i="${gi}"] .sec-sw`, (e, h) => { const c = document.createElement('i'); c.style.color = h; document.body.appendChild(c); const w = getComputedStyle(c).color; c.remove(); return getComputedStyle(e).backgroundColor === w; }, h);+  await undo(page);+  check('Ctrl+Z puts the previous colour back, in the plan and on the page', (await savedPlan(X.url)).phases[gi].color === hex && await swatchIs(hex));+  await redo();+  check('redo puts #aabbcc back, in the plan and on the page', (await savedPlan(X.url)).phases[gi].color === '#aabbcc' && await swatchIs('#aabbcc'));+  pl = await savedPlan(X.url);+  check('the name and aside are untouched by the colour undo and redo', pl.phases[gi].name === 'Gamma renamed' && pl.phases[gi].lead === 'the long pole');++  // ── D-1: an open Remove form survives an undo and still means the section it was opened on ──+  await rowBtn('Beta', 'down'); await settle(page, 200);      // Alpha, Gamma renamed, Beta, ...+  await rowBtn('Beta', 'del'); await settle(page, 100);        // the form, focus on its select+  await undo(page);                                             // Beta goes back ABOVE Gamma renamed+  check('after Ctrl+Z the open Remove form is on Beta\'s new row', await page.$eval('.sec-row.sec-editing .sec-name', e => e.firstChild.textContent) === 'Beta');+  await page.click('.sec-row.sec-editing .sec-go'); await settle(page, 200);+  HOME = Object.fromEntries(Object.entries(HOME).map(([k, v]) => [k, v === 'Beta' ? 'Alpha' : v]));+  await expectState('and Move and remove removes Beta (into Alpha), not the section now in its old slot', N = ['Alpha', 'Gamma renamed', 'Echo', 'Foxtrot', 'Delta']);+  await undo(page);+  const HOME1 = Object.fromEntries(Object.entries(HOME).map(([k, v]) => [k, k === 'Beta work' ? 'Beta' : v]));+  await expectState('Ctrl+Z of a remove puts the section and its rows back', ['Alpha', 'Beta', 'Gamma renamed', 'Echo', 'Foxtrot', 'Delta'], HOME1);+  await redo();+  await expectState('redo removes it again', N);++  // ── E-1: remove a MIDDLE section into a LATER one, rows after it keep their sections ──+  await rowBtn('Gamma renamed', 'del'); await settle(page, 100);+  await page.$eval('.sec-row.sec-editing .sec-dest', s => { s.value = [...s.options].find(o => o.textContent === 'Delta').value; });+  await page.click('.sec-row.sec-editing .sec-go'); await settle(page, 200);+  HOME = Object.fromEntries(Object.entries(HOME).map(([k, v]) => [k, v === 'Gamma renamed' ? 'Delta' : v]));+  await expectState('Remove a middle section into a later one', N = ['Alpha', 'Echo', 'Foxtrot', 'Delta']);+  await rowBtn('Echo', 'del'); await settle(page, 100);+  await page.click('.sec-row.sec-editing .sec-no'); await settle(page, 100);+  check('Cancel on a remove leaves the section alone', JSON.stringify(await listNames()) === JSON.stringify(N));+  await rowBtn('Echo', 'del'); await settle(page, 100);+  await page.click('.sec-row.sec-editing .sec-go'); await settle(page, 200);+  await expectState('Remove deletes an empty section', N = ['Alpha', 'Foxtrot', 'Delta']);++  const log = await (await fetch(X.url + 'changelog')).json();+  const acts = log.events.filter(e => e.via === 'sections').map(e => e.action);+  check('every section change is in the changelog, as it was from the modal', ['phase.create', 'phase.reorder', 'phase.rename', 'phase.lead', 'phase.color', 'phase.delete'].every(a => acts.includes(a)), JSON.stringify([...new Set(acts)]));++  // ── D-4: the row chords do nothing on Setup ──+  await view(page, 'table');+  await page.click('#tableView tbody tr[data-key] td.c-owner').catch(() => {});+  await page.evaluate(() => document.querySelector('#tableView tbody tr[data-key] td:first-child')?.click()); await settle(page, 150);+  await page.keyboard.press('Escape'); await settle(page, 100);+  await page.keyboard.down('Control'); await page.keyboard.press('KeyC'); await page.keyboard.up('Control');+  const before = JSON.stringify((await savedPlan(X.url)).items.map(r => r.name + '@' + r.phase));+  await view(page, 'setup');+  await page.keyboard.down('Alt'); await page.keyboard.press('ArrowDown'); await page.keyboard.up('Alt');+  await page.keyboard.down('Control'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control'); await settle(page, 300);+  check('Alt+Down and Ctrl+V on Setup move and paste nothing', JSON.stringify((await savedPlan(X.url)).items.map(r => r.name + '@' + r.phase)) === before);++  await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+  check('a refresh on Setup comes back to Setup', await page.evaluate(() => document.documentElement.getAttribute('data-view')) === 'setup' && JSON.stringify(await listNames()) === JSON.stringify(N));   await page.close();++  // ── E-5: the snapshot (file://, no server) ──+  // Taken after the V-A reorder above. Every change is checked on the snapshot's own page:+  // the Setup list and counts, and the Table's Phase column, which is each row's section.+  const snapFile = join(TMP, 'sections-snapshot.html'); writeFileSync(snapFile, snapHtml0);+  const sp = await openPage(browser, 'file://' + snapFile);+  const snapPhaseCol = async () => { await view(sp, 'table'); const m = await sp.$$eval('#tableView tbody tr[data-key]', trs => trs.map(tr => [tr.querySelector('td.c-name')?.textContent.trim(), tr.querySelector('td.c-phase')?.textContent.trim()])); await view(sp, 'setup'); return m; };+  await view(sp, 'setup');+  let SN = ['Alpha', 'Beta', 'Gamma', 'Echo', 'Foxtrot', 'Delta'];+  check('the snapshot opens with the sections and their rows as saved', JSON.stringify(await listNames(sp)) === JSON.stringify(SN) && JSON.stringify(await counts(sp)) === '[2,1,2,0,0,1]', `${await listNames(sp)} ${await counts(sp)}`);+  await sp.type('#secNew', 'Offline'); await sp.click('#secAdd'); await settle(sp, 300);+  check('the snapshot adds a section with no server', JSON.stringify(await listNames(sp)) === JSON.stringify([...SN, 'Offline']));+  await sp.click('.sec-row[data-i="1"] .sec-btn[data-a="edit"]'); await settle(sp, 100);+  await sp.$eval('.sec-row[data-i="1"] .sec-nm', e => { e.value = 'Beta offline'; });+  await sp.click('.sec-row[data-i="1"] .sec-ok'); await settle(sp, 200);+  await sp.click('.sec-row[data-i="0"] .sec-btn[data-a="down"]'); await settle(sp, 200);+  SN = ['Beta offline', 'Alpha', 'Gamma', 'Echo', 'Foxtrot', 'Delta', 'Offline'];+  check('the snapshot renames and reorders', JSON.stringify(await listNames(sp)) === JSON.stringify(SN) && JSON.stringify(await counts(sp)) === '[1,2,2,0,0,1,0]', `${await listNames(sp)} ${await counts(sp)}`);+  await sp.click('.sec-row[data-i="2"] .sec-btn[data-a="del"]'); await settle(sp, 100);+  await sp.$eval('.sec-row.sec-editing .sec-dest', s => { s.value = [...s.options].find(o => o.textContent === 'Delta').value; });+  await sp.click('.sec-row.sec-editing .sec-go'); await settle(sp, 200);+  const col = Object.fromEntries(await snapPhaseCol());+  check('the snapshot removes a middle section into a later one, and the table agrees', JSON.stringify(await listNames(sp)) === JSON.stringify(['Beta offline', 'Alpha', 'Echo', 'Foxtrot', 'Delta', 'Offline'])+    && col['Gamma work'] === 'Delta' && col['Beta work'] === 'Beta offline' && col['Alpha work'] === 'Alpha' && col['Delta work'] === 'Delta', JSON.stringify(col));+  await sp.close();++  // A plan authored to open on Setup does.+  const Y = await startServer({ project: { title: 'Opens on setup', view: { mode: 'setup' } },+    phases: [{ name: 'Only', start: '2026-09', end: '2026-12' }],+    items: [{ type: 'task', id: 'x', name: 'X', start: '2026-10-05', end: '2026-10-09', phase: 0, uid: '00001' }] }, 'opens-setup');+  const yp = await openPage(browser, Y.url);+  check('project.view.mode "setup" opens the plan on the Setup lens', await yp.evaluate(() => document.documentElement.getAttribute('data-view')) === 'setup' && await yp.$eval('#setupView', e => window.__uiVis(e)));+  await yp.close(); }  // ── Cleanup: always, however the run ends ──