app
Adom Gantt
Public Made by Adomby adom
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, push
← Commit history
Publish 0.57.0
11 files changed
+2662−65
.gitignore+5@@ -1 +1,6 @@ examples/*-state.json+test/node_modules/+test/out/+s1.log+s2.log+server.log
README.md+17−4@@ -24,7 +24,7 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc **Resizable table columns.** Drag the divider between any two headers, or double-click it to fit the widest value. Widths persist. -**Collapse a lane.** The chevron at the right of each phase header hides that lane's rows and shows how many. Collapsing hides rows, not time: the axis does not move and Fit still fits the whole plan. This is your view only, it is not part of the plan.+**Collapse a lane.** The chevron at the right of each phase header hides that lane's rows and shows how many. Collapsing hides rows, not time: the axis does not move and Fit still fits the whole plan. This is your view only, it is not part of the plan. **Collapse all** at the start of the legend folds every lane to its header in one press, and reads **Expand all** once they are all folded; the table follows the chart. **Rename in place.** Click a name in the chart's label column to edit it right there; double-click for the full editor with every field. Drag a task across a lane boundary and it changes lane, one undo for the whole move. @@ -62,7 +62,9 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc **Dependency arrows.** Teal routes with rounded elbows and arrowheads show task relationships: out of the source bar's end sideways, down, and into the next bar's start sideways again, steering round any bar in the way rather than through it. Click any bar to see incoming ("needs") and outgoing ("blocks") dependencies. Add or remove dependencies inline. -**Legend filtering.** Click any phase, "Milestone", "Dependencies" or "Today" in the legend bar to toggle visibility. Dimmed items fade to 15% opacity; hiding "Today" removes the marker line.+**Legend filtering.** Click any phase, "Milestone", "Dependencies" or "Today" in the legend bar to toggle visibility. Dimmed items fade to 15% opacity; hiding "Today" removes the marker line. The legend wraps onto as many lines as the plan needs, so a plan with twenty sections shows all twenty chips.++**How long, and how long left.** The header says **Project Duration** and **Time Remaining**, worked out from the plan's own first and last dates and today, never from the cell size, leaving cancelled work out, and updated on every edit. They stay whole at every window width: the header sheds button words, then wraps, before it lets them be cut. A printed chart names the plan in a title line of its own. Hover either for the exact dates and the calendar and working-day counts. The plan itself is named once, in the brand block at the far left; its title and description are on that block's tooltip. **New items remember the last one.** After you create a task or milestone, the next New panel prefills every field from it: type, lane, owner, status, both dates, notes and the provenance label. Only the **name** starts empty, so adding several items for the same person in the same lane in the same week is just names. Leave provenance at its default and it stays a default, following whoever is signed in rather than freezing a name into the session. It lasts the page session only: a refresh clears it, and it is never saved to your preferences or the plan, so it is never handed to anyone else. Cancel, Escape and a create rejected for a missing name all leave the memory exactly as it was. @@ -80,9 +82,9 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc **Brand phase colors.** A phase with no explicit `color` cycles the three Adom hues: teal, purple, blue. There is no background band behind a phase: the bar colour and the group header carry the section on their own, so colour on the chart means exactly one thing, which phase a bar belongs to. The hues are theme-aware, so the same plan is on-brand on screen and legible on paper. -**Move the whole plan.** The project start sits in the header. Change it and every task, milestone and phase shifts by the same number of days, so durations and the gaps between them survive. Revert puts the plan back to the data file's own start. It is one undo step and one changelog entry, not 26.+**Move the whole plan.** The project start sits in the header, as an exact date at every cell size. Change it and every task, milestone and phase shifts by the same number of days, so durations and the gaps between them survive. Revert puts the plan back to the data file's own start. It is one undo step and one changelog entry, not 26. -**Text size.** A-minus and A-plus at the right end of the header step the whole app between 85% and 150% in seven steps, chart and table together. The percentage between them is the reset. Rows, bars and the axis grow with the type, so nothing is clipped, and the choice persists.+**Text size.** A-minus and A-plus at the right end of the header step the whole app between 85% and 150% in seven steps, chart and table together. The percentage between them is the reset. Rows, bars and the axis grow with the type, so nothing is clipped, and the choice persists across a refresh, in the live app and in an exported HTML snapshot alike. **Fit to the pane.** One click on Fit picks the finest cell size that shows the whole plan without scrolling sideways, then stretches the cells to fill the width exactly. Click it again after resizing the window to re-fit. @@ -351,6 +353,7 @@ table, the item editor or the batch editor are taken exactly as typed. | 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 | | A- / A+ | Step the text size for both views. Click the percentage to reset |+| Collapse all / Expand all | Fold every section to its header, or open them all. Start of the legend | | Hover "today" | The current date | | Click a table cell | Edit owner, start, deadline, status or notes in place | | Click a table header | Sort by that column (deadline ascending by default) |@@ -393,6 +396,16 @@ Two example datasets are included: - `examples/sample-product-launch.json`: 4-phase hardware product launch (26 items, 4 milestones, dependency graph) - `examples/smt-line.json`: 5-phase SMT production line build (43 items, 8 milestones) +## Tests++A browser suite drives the real app in headless Chrome against a seeded 20-section plan on a scratch port: every behaviour above that 0.57.0 touched, a control-coverage gate that fails on any control nobody declared, a phone and two desktop widths, and a page-error gate on every page.++```+cd test && npm install # puppeteer-core, once; Chrome from ~/.cache/puppeteer+node test/ui_test.mjs # exits non-zero on any failure+node test/ui_test.mjs --keep --plan path/to/plan.json # also a real plan (copied first); keep screenshots in test/out/+```+ ## Collaboration Each team member runs their own server instance with the same data file. Use Export (JSON) to share the current state and Import (JSON) to load a colleague's version. The data file and sidecar state file can also be committed to a shared git repo for async collaboration.
SKILL.md+13−7@@ -97,9 +97,9 @@ Create a JSON file with this structure: | Field | Type | Required | Description | |-------|------|----------|-------------|-| title | string | yes | Project name shown in header |-| subtitle | string | no | Short tagline (default: "Gantt chart") |-| appName | string | no | Shown in brand zone (default: "adom-gantt") |+| title | string | yes | Project name. Since 0.57.0 it is NOT painted into the header as a second bold title; it is the browser tab title, it is on the brand block's tooltip, and it is what the brand block shows when there is no appName |+| subtitle | string | no | Short tagline. Since 0.57.0 it is on the brand block's tooltip only, no longer a dimmed line in the header |+| appName | string | no | Shown in the brand block at the far left, the one place the header names the plan. With no appName the brand shows the title (it used to show the literal "adom-gantt"). A PRINTED chart carries the title and subtitle in a print-only line above the header, since tooltips do not print | | nextUid | number | no | The uid counter. Written and owned by the server, never by you. See uid below | | view | object | no | The author's intended default view, see below | @@ -217,12 +217,14 @@ 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+- **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+- **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, the long labels (Duration, Remaining), and finally wraps onto a second row. 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 - **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+- **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+- **Collapse all / Expand all** leads the legend (0.57.0), in the chart and the table. One press collapses every section to its header; when every section with rows is already collapsed it expands them all. A mixed state collapses. It is the same view state as the per-lane chevrons (`prefs.collapsed`), so it persists across a refresh, follows the chevrons (open the last lane by hand and it reads Collapse all again), and the table agrees with the chart. With everything collapsed the table says so rather than "no items match" - **Click a bar** to open the date editor with start/end pickers and dependency management. It is a compact 256px square (`--ed-sq`, one token for both dimensions): the row's name across the top, the two dates, the note, the dependency list and the buttons, at desktop density, so it covers as little of the chart as it can. The dependency list is the only part that flexes and the only part that scrolls; the buttons are pinned to the bottom edge. The milestone panel and the batch panel take the same compact metrics; the full **Edit item** panel, which is filled in rather than glanced at, keeps the full ones - **Drag bar edges** to resize (changes start or end date). Every timeline drag snaps to the grid the axis is currently drawing: day boundaries at the day cell size, week starts at weeks, month, quarter and year firsts at the coarser ones. Resizing the start leaves the end where it is and the mirror for the end; dragging the bar body snaps the START and preserves the DURATION, so the end lands at start plus the same number of days. The preview snaps live, line to line, and is exactly what gets committed. The shortest bar a resize can produce is the first grid line past the fixed edge, so it is never zero or negative. Milestones snap the same way. Dates set explicitly (table cells, the item modal, the batch editor, the project-start shift) are taken exactly as given and are never rounded - **Sign in with Adom is required** for every page load and every change. Unauthenticated visitors get a sign-in page and no plan data. Sessions are an HttpOnly cookie, 30 days, and survive a restart. `--no-auth` turns it off for single-user local use@@ -257,7 +259,7 @@ rather than a category. - **Auto-save** to `<datafile>-state.json` sidecar (800ms debounce, flushed immediately if the page is closed or hidden, so the changelog and the sidecar cannot disagree) - **Auto-load** saved state on page refresh - **Dynamic date range** computed from the data every render-- **Project start** in the header. Changing it moves the WHOLE plan: every task, milestone and phase shifts by the same number of days, so durations and gaps are preserved. Revert puts it back to the data file's own start. This is a real edit, recorded and undoable, not a view setting+- **Project start** in the header, shown as an exact date (`21-Sep-2026`) at every grain since 0.57.0; it used to show only the month at any grain but Days. At the coarser grains its picker still moves the plan by whole months and keeps the day of the month, clamped to the month's length (the 31st moved to February lands on the 28th or 29th, not on 3 March). Changing it moves the WHOLE plan: every task, milestone and phase shifts by the same number of days, so durations and gaps are preserved. Revert puts it back to the data file's own start. This is a real edit, recorded and undoable, not a view setting - **Cell size** picker: Days, Weeks, Months, Quarters, Years. Each grain has its own minimum cell width sized to the widest label it draws, so a fine grain scrolls rather than squashing while a coarse one stretches to fill the pane. Day and week positions are exact; month, quarter and year approximate within a month - **Bar notes** put a short label on the bar body. Double-click a bar to type one, or use the note field in the editor - **Milestones are editable too**: drag one sideways to move its date, or click it for a panel with its name, date and description. Line style is the default: the tag on the dashed rule is the handle, and the milestone's grid row is hidden since the rule already carries it. Diamond style keeps the row. Long titles wrap, and any tag that would still collide with its neighbour drops to a second row, so tags never overlap@@ -292,7 +294,7 @@ named in its own column. **Table column widths** are draggable and persist in `prefs.tableCols`; the table switches to fixed layout once any width is user-set. -**Collapse.** Each phase header carries a chevron and a row count. Collapsed lanes are view state in `localStorage` (`prefs.collapsed`), not plan state, so they are per-person and never written to the data file or the sidecar. Collapsing hides rows only: the date axis and Fit are unaffected.+**Collapse.** Each phase header carries a chevron and a row count, and Collapse all / Expand all at the start of the legend does every lane at once. Collapsed lanes are view state in `localStorage` (`prefs.collapsed`), not plan state, so they are per-person and never written to the data file or the sidecar. Collapsing hides rows only: the date axis and Fit are unaffected. **Label editing.** A single click on a chart label edits the name inline; a double-click opens the full item editor. Dragging a task across a group header changes its phase. @@ -612,6 +614,10 @@ carry their `uid` there the same way a data-file row carries it. `deleted` holds row keys of rows the user removed; their uids are retired and are not reissued, which 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.+ ## Example ```bash
docs/BUILD-NOTES.md+146@@ -3,6 +3,152 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-09-29 (0.57.0, a quieter header, collapse all, a legend that wraps, a text size that sticks)++Six things Drew asked for, from two screenshots of the instaPCB launch plan (20 sections,+114 rows). `gantt.html` carries all of it; `server.js` is unchanged. The package also gains+its first browser test suite, `test/ui_test.mjs`.++### 1. Text size did not survive a refresh: two causes, both reproduced first++Reproduced in headless Chrome before a line changed, against a copy of the real plan:++* **Exported snapshot.** A+ twice read 120% and wrote `fs: 1.2` to localStorage; the+ reload read 100%. `savedPrefs()` returned `{}` whenever `SNAPSHOT` was true, so the+ snapshot wrote the size and never read it back. That is the report exactly: every+ refresh, every time. The instaPCB plan's `index.html` is such a snapshot.+* **Live app, two tabs.** The single-tab live app kept 120% across a reload, so the+ suspect list's "the live app is fine" was nearly right. It is not fine with a second+ tab: the key is one per origin, `savePrefs()` writes the WHOLE prefs object, and a tab+ opened before the change still holds the old size. Its next save of anything (a grain+ change was enough) wrote 100% back over 120%, and the next refresh read that.++Fixes. `READER_PREFS = ['fs']`: a snapshot reads the size from the reader's storage and+writes back ONLY the size, merged into what is stored. Everything else in a snapshot still+opens at the author's view, and a snapshot no longer overwrites the grain, lens and collapse+set of a live app on the same origin (every `file://` snapshot shares one). In the live app+a `storage` listener adopts a size set in another tab, live, without writing, so no open tab+holds a stale size to write back. The suite proves both: run against 0.56.0 it fails the+two-tab and snapshot checks, run against 0.57.0 it passes them.++Not fixable client-side, and worth knowing: localStorage is per origin, so the same plan+opened through a different host or port starts at 100% there.++### 2. Header declutter++`.subject-name` and `.subject-sub` are gone from the template and their CSS with them. The+brand block is the one place the header names the plan: `appName`, else the title (it used+to fall back to the literal "adom-gantt"). Its tooltip carries appName, title, subtitle and+the version. `document.title` is unchanged. The brand's `max-width` went from 20ch to 30ch+because it now shows titles, which run longer than app names.++### 3. The start date is exact at every grain++`syncStartBtn` formats the full date always. The month grid behind it at the coarser grains+still moves the plan by whole months, and now clamps the day to the target month: a start on+the 31st moved to February used to land on 3 March, which the old "Sep 2026" label hid and+an exact label would have shown. Revert's tooltip names the exact date too.++### 4. Project Duration and Time Remaining replace "25 weeks"++The old figure was `UNITS`, the axis cell count, padding included. The new pair is computed+by `planSpan()` from the rows: earliest task start or milestone date to latest task end or+milestone date, both days counted, the same span Analytics reports (cancelled rows included,+as the start button already includes them). Format: `N days` under two weeks, `16w 4d` under+half a year, then months and years to one decimal; the tooltips carry the exact calendar and+working-day counts and both dates. Remaining reads `ended` after the last date, counts from+today with a "not started" tooltip before the first, and both read `none` on an empty plan.++Live updates go through the hubs rather than each edit path: `scheduleSave()` (every edit,+undo and redo included) and `rebuildTimeline()` both call `syncStartBtn()`, which now calls+`syncPlanSpan()`. A minute tick and `visibilitychange` repaint when the date rolls over.++**Stacked, not inline.** Side by side the pair needs about 300px; at 1440px the header had+about 190 left and the second figure was cut mid-word ("Time R..."), seen in a screenshot.+Stacked in two 10.5px lines inside the 44px header it needs half that. (The first cut+then shed the version and the long labels at guessed breakpoints; review round 1 below+replaced every header breakpoint with a measured fit.)++### 5. Collapse all++A button at the head of the legend, so it is there in the chart AND the table (the lane+chevrons exist only in the chart). It drives the existing `collapsed` Set,+`prefs.collapsed` and `applyCollapse()`, so it persists like a chevron and the table agrees.+Mixed state collapses; only when every lane that has rows is shut does it expand. Empty lanes+do not vote, or an empty section would pin it on "Collapse all". `applyCollapse()` resyncs+its label, so a hand-opened lane turns it back. `rebuildLegend()` now inserts chips after it+rather than at `firstChild`. An all-collapsed table says why it is empty.++### 6. The legend wraps++`flex-wrap: wrap`, `overflow` no longer hidden. The plan asked that everything that measured+the one-line legend keep working, and the answer on reading the code is that nothing measured+it: the chart and the table are flex children that take what is left, their sticky rows stick+inside their own scroll boxes, and the timeline's ResizeObserver re-runs the layout when the+legend changes height. The suite measures it anyway, at 100% and 150% text and across a+1440 to 1100 to 1440 resize: axis at the pane top, axis 46px times the text size, pinned+section header flush under the axis, table header flush with the top of its scroller.++### Beyond the brief, both found by looking at screenshots++* **The header wraps when it runs out of room.** It already overflowed in 0.56.0 (New and+ the text size were cut off at 1100px, the whole right half at 393px) because `html` is+ `overflow: hidden`. The first cut wrapped below a fixed 1180px; round 1 made it measured.+* **The table's filters wrap on a phone.** In the wrapped legend they were the one thing+ still wider than 393px.++### Tests++`test/ui_test.mjs`, 222 checks on the last full run with `--plan` (see round 1), `node test/ui_test.mjs` exits non-zero on+any failure. Built to the app-testing skill: own server on a scratch port against a seeded+synthetic 20-section plan in a temp dir, the page's clock pinned to 29-Sep-2026, page-error+gate on every page, flows that assert effects, a coverage manifest (no orphans, no fiction,+every safe control clicked), 393/1440/1920 with no sideways scroll, and a cheap leak watch+(listeners and nodes after a forced GC). The app's state lives in one closure, so the oracle+for the readouts is the plan the SERVER saved (`/export/json`) after each edit, which makes+every live-update check end to end.++Two lessons from building it. The date picker hangs over the legend, so the coverage walk's+"click every chip" in the picker state really clicked month cells and moved the plan; the walk+now clicks only a control that is on top at its own centre, and asserts afterwards that the+walk left the plan unchanged. And a background tab gets no animation frames, so "wait two+frames" hung the two-tab test until it had a timeout.++Not built: golden-image visual regression (no web fonts in the container, so a baseline would+bless the fallback face) and the Ralph pass; the screenshots under `test/out/` (`--keep`) were+read by eye instead.++### Review round 1 (same day)++An independent reviewer drove the build in Chrome and reproduced eleven defects. What+changed:++* **The readouts were clipped or hidden at common widths** (1181 to about 1390px and 1621+ to about 1745px on the real plan, 1440 with no appName), and the right-hand controls ran+ off the edge just above the old 1180px wrap. Cause: `.subject` was the flexible,+ clipping item, so it absorbed every shortfall. The pixel breakpoints are gone. `.subject`+ never shrinks now (`flex: 1 0 auto`), and `fitHeader()` measures the row and takes the+ first of five states that fits: nothing shed, version shed, button words shed, long labels+ shed, wrapped. It re-runs on resize, on a lens switch, when a header group changes size+ (ResizeObserver on the groups, not the header, so wrapping does not wake it) and when+ fonts load. A new sweep in the suite (every 40px 360 to 2000 plus the named widths, with+ and without appName, at 100% and 150%) failed on the previous build at 17 and 24 widths+ and passes now, also at a 20px step and on the real plan.+* **A stale tab still overwrote every pref but the size** (reproduced with the collapse+ set). `savePrefs()` is merge-on-write: only keys this tab changed since it last read or+ wrote them go into a fresh read of storage. The baseline is retaken at the end of startup,+ so the clamps and the in-memory critical-path drop are not written back.+* **Print lost the plan's name.** A print-only title line carries the title and subtitle;+ Collapse all does not print.+* **Cancelled rows are out of the clock**, matching Auto-schedule, the critical path and+ the hours. The tooltip counts what was left out; all-cancelled reads `none` with a reason.+ Analytics' own span card still includes them and was left alone.+* **Remaining longer than Duration before the start** is kept (it counts from today, as+ asked) and now explains itself with a `not started` note on the readout.+* Smaller: stored prefs that parse to an array are ignored, the start button pads a+ year-month to its first day, the suite picks only free ports and checks the answering+ server is its own, and `s1.log`, `s2.log` and `server.log` are excluded from the package.+ ## Build notes, 2026-09-01 (0.56.0, copy and paste rows, and the status vocabulary in lifecycle order) Two changes Drew asked for, and one drifted surface the second of them turned up on the
docs/plan-0.57.0-header.mdadded+153@@ -0,0 +1,153 @@+# adom-gantt 0.57.0: header cleanup, collapse all, wrapping legend, sticky text size++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.++## Working copy++- Source: /home/adom/project/adom-gantt (files-only clone of adom/adom-gantt 0.56.0).+ The app is `gantt.html` (one file, about 10k lines) served by `server.js`.+- Real plan to exercise: /home/adom/project/instapcb-launch/gantt/instapcb-launch.json+ (20 phases, 114 items, 2026-09-21 to 2027-01-14, grain week). Never write to that+ file or beside it; copy it to a temp dir first, since the server writes sidecars.+- Run: `node server.js --data <copy>.json --port <port> --no-auth`.+- Test tooling already in place: `test/node_modules/puppeteer-core` and Chrome at+ `/home/adom/.cache/puppeteer/chrome/linux-*/chrome-linux64/chrome` (launches with+ `--no-sandbox --disable-gpu --disable-dev-shm-usage`).++## Behaviour wanted++1. **Collapse / expand all sections.** One control that collapses every phase+ lane and, when all are already collapsed, expands them all. Its label or icon+ and tooltip say which of the two it will do. It reuses the existing per-lane+ collapse (`collapsed`, `prefs.collapsed`, `applyCollapse`), so the state+ persists like a per-lane collapse does and both lenses (chart, table) agree.+ A mixed state (some collapsed) needs a defined answer.++2. **Text size persists across a refresh.** The A-/A+ control writes `prefs.fs`+ to localStorage, yet Drew sees the size reset on refresh. Find the real cause+ before changing anything. Candidates: the exported HTML snapshot sets+ `SNAPSHOT = true` and `savedPrefs()` then returns `{}`, so nothing a reader+ sets survives there; `project.view` defaults; something re-clamping `fs`.+ Text size is a statement about the reader, not the author, so it should+ survive a refresh in the live app AND in an exported snapshot, while the+ snapshot keeps opening with the author's view for everything else.++3. **Header declutter** (first screenshot). The name in the brand block at the+ far left stays. Remove the bold repeat of the project title+ (`.subject-name`) and the dimmed description (`.subject-sub`) from the+ header. `project.title` and `project.subtitle` stay valid data fields; they+ just stop being painted into the header (the title can remain in+ `document.title` and the brand tooltip). If a plan has no `appName`, the+ brand should show the project title rather than the literal "adom-gantt".++4. **Start date is always an exact date.** The `starts` button currently shows+ a month ("Sep 2026") at any grain except day. It must show the full date at+ every grain.++5. **Replace the cell count.** "25 weeks" is the number of axis cells at the+ current grain. It changes with the grain picker, says nothing about the+ project, and contradicted the plan's own 16-week statement. Replace it with+ two readouts:+ - `Project Duration: N` from the plan's first date to its last date.+ - `Time Remaining: M` from today to the plan's last date.+ Both must be independent of the grain picker, update live when dates change+ (drag, editor, table edit, start shift, auto-schedule, undo, redo, paste,+ delete), and have defined answers for a plan that has not started, a plan+ that has ended, and an empty plan. Choose a unit format a person can read at+ a glance and make the tooltip state the exact dates it was computed from.++6. **Legend wraps** (second screenshot). With 20 phases the legend clips at the+ pane edge (`flex-wrap: nowrap; overflow: hidden`). It must wrap onto as many+ lines as it needs so every phase chip is visible and clickable. Anything+ that measured the old single-line height (sticky axis, pinned section+ header, scroll-follow maths, the table's sticky header) must keep working+ when the legend is two or three lines tall and when it changes height on+ resize or on a text-size change.++## Tests (app-testing skill)++The repo has no test suite. Build one under `test/` following+`~/.claude/skills/app-testing/SKILL.md`:++- Self-hosted: spawn `server.js --no-auth` on a scratch port against a temp copy+ of seeded data; clean up always.+- Real Chrome, page-error gate on every page.+- Flow tests asserting the EFFECT of each of the six behaviours above, plus the+ existing journeys they could break (per-lane collapse, legend filter click,+ grain change, text size steps, start-date picker opens).+- Coverage gate: a manifest of every control the app renders in every state,+ no orphans, every required control present, click every safe control.+- Viewport matrix including 393px wide and a desktop width; no sideways page+ scroll.+- Today's date must be deterministic in tests (pin the clock in the page).+- `node test/ui_test.mjs` exits non-zero on any failure.++## Housekeeping++- Bump to 0.57.0 (`package.json`, and wherever else the version is stated).+- Update `SKILL.md` and `README.md` where they describe the header, the text+ size, collapse, and the legend. House style: no em dashes in prose.+- Append to `docs/BUILD-NOTES.md` in its existing style.+- `test/node_modules` must not ship: keep it out of the package and ignored.+- Do NOT publish to the wiki and do NOT touch+ /home/adom/project/adom_modules. Publishing is Drew's call.++## Build notes++## Build notes - 2026-09-29++What changed against this plan, and why. The full account is the 0.57.0 entry at the top+of `docs/BUILD-NOTES.md`.++1. **Text size had two root causes, not one.** The snapshot suspect was right: in an+ exported snapshot `savedPrefs()` returned `{}`, so the size was written and never read+ (reproduced: 120% became 100% on every reload). The live app was NOT clean, though: with+ a second tab open, that tab's next save of any pref wrote its stale `fs` back over the new+ one (reproduced: B at 120%, A changes grain, B reloads at 100%). Fixed both: `READER_PREFS`+ (a snapshot reads and writes only `fs`, merged into what is stored) and a `storage`+ listener that keeps every open tab's `fs` current. Side effect worth having: a snapshot no+ longer overwrites a live app's grain, lens and collapse set on a shared origin.+2. **The readouts are stacked, not inline.** Inline, "Time Remaining" was cut off mid-word+ at 1440px (seen in a screenshot). Two stacked lines fit. The version hides below 1500px+ (it is on the brand tooltip now) and the labels shorten to "Duration" / "Remaining" below+ 1240px. The plan's labels are kept verbatim at desktop widths.+3. **Duration counts both ends** (a one-day plan is 1 day), matches the Analytics span, and+ includes cancelled rows, because the start button already does. Format: days, then+ `16w 4d`, then months, then years; exact counts on the tooltips.+4. **Collapse all lives at the head of the legend**, not in the header: the header has no+ room, the per-lane chevrons exist only in the chart, and the legend is present in both the+ chart and the table, so one control serves both lenses. Mixed state collapses; empty lanes+ do not count towards "all collapsed".+5. **Nothing measured the legend's height.** The plan listed things that might; reading the+ code, all of them are relative to their own scroll boxes, and the layout is flex. So the+ fix is CSS only, and the suite measures the sticky geometry to prove it rather than trusting+ the reading.+6. **Beyond the brief:** the header now wraps below 1180px (it already ran off the right edge+ at 1100px and at 393px in 0.56.0, and the longer start date made that worse), the table's+ filters wrap on a phone, and the month picker clamps the day (the 31st moved to February+ used to land on 3 March; an exact start label would now show that).+7. **Test oracle is the server.** The app keeps its state in one closure, so the suite cannot+ read `items`; it reads `/export/json` after each edit's save lands and computes the expected+ readouts itself. That makes each live-update check end to end.+8. **BUILD-NOTES.md is newest first**, so the 0.57.0 entry went at the top rather than at+ the tail.+9. `.adomignore` added (plus `.gitignore` lines) to keep `test/node_modules/` and `test/out/`+ out of the package. The `.adomignore` syntax is assumed to be gitignore-like; check it on+ the first publish.++### Round 1 review fixes++10. **Header layout is measured, not breakpointed.** The first build's fixed breakpoints+ left the readouts clipped at 1181 to about 1390px and elsewhere. `fitHeader()` sheds+ version, button words, long labels, then wraps, choosing the first state whose content+ fits; the readouts never shrink. Swept in the suite 360 to 2000px, with and without+ appName, at 100% and 150%.+11. **Prefs are merge-on-write** for every key, not just the size, so no stale tab can undo+ another tab's collapse, theme or grain.+12. **Cancelled rows are left out of Duration and Remaining** (the plan's first reading had+ them in, for consistency with the start button; consistency with Auto-schedule, the+ critical path and the hours is the better rule). All-cancelled reads `none`.+13. **Not-started is shown on the readout**, not only on the tooltip; Remaining still counts+ from today.+14. A print-only title line, since the header no longer names the plan in print.
gantt.html+441−52@@ -172,7 +172,13 @@ html, body { overflow: visible !important; height: auto !important; } .labels-col, .timeline-col { overflow: visible !important; } header .actions, .save-indicator, .conflict-bar { display: none !important; }+ .print-title { display: block !important; }+ /* A button on paper is a thing nobody can press. */+ .legend-item.collapse-all { display: none !important; } }+.print-title { display: none; padding: 10px 16px 6px; font-family: 'Familjen Grotesk', sans-serif; color: var(--text); }+.print-title .pt-name { font-size: 16px; font-weight: 600; }+.print-title .pt-sub { font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text2); margin-top: 2px; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html, body { font: 13px/1.4 'Satoshi', -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; background: var(--bg); color: var(--text); overflow: hidden; height: 100%; } @@ -181,22 +187,37 @@ header { display: flex; align-items: center; gap: 12px; padding: 0 16px; height: header .brand { display: flex; align-items: center; gap: 8px; color: var(--accent); font-size: 13px; font-weight: 600; cursor: default; flex-shrink: 0; } header .brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; } header .brand .app-name { color: var(--accent); }-/* Bounded on purpose. The subtitle is author text of any length, and unbounded it- pushed everything after it across the header (and off a narrow one). The full- text is still the header's tooltip, and the project title sits right after it. */-header .brand .app-name { max-width: 20ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+/* Bounded on purpose. It is author text of any length, and unbounded it pushed+ everything after it across the header (and off a narrow one). 30ch since 0.57.0,+ up from 20: with the bold title gone from the header this is the one place the+ plan is named, and it falls back to the project title, which runs longer than an+ appName. The full text is on the brand's tooltip. */+header .brand .app-name { max-width: 30ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } header .brand .sep { color: var(--text3); font-weight: 400; } header .vsep { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; }-header .subject { display: flex; align-items: baseline; gap: 8px; min-width: 0; flex: 1; cursor: default; overflow: hidden; }-/* The one subtitle. It gives up width before the title does, so on a narrow header- the thing being named survives and its description is what shortens. */-header .subject-sub { color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 12px; font-weight: 400; min-width: 0; flex-shrink: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }-/* Does not shrink: the subtitle absorbs every bit of squeeze first, so the thing- being named is the last text in the header to give up a character. max-width- keeps it inside the box in the extreme case instead of shoving .actions along. */-header .subject-name { color: var(--text); font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; max-width: 100%; }-header .subject-meta { display: flex; align-items: center; gap: 6px; color: var(--text2); font-family: 'JetBrains Mono', monospace; font-size: 11px; white-space: nowrap; flex-shrink: 0; }-header .subject-meta .sep { color: var(--text3); }+header .subject { display: flex; align-items: center; gap: 8px; flex: 1 0 auto; cursor: default; }+/* 0.57.0: the bold title (.subject-name) and the dimmed subtitle (.subject-sub) are+ gone from here; the brand block names the plan and its tooltip carries both. What+ the group holds now is the plan's clock, Project Duration and Time Remaining, and+ the version. It still takes the free width so the start control and the right-hand+ actions keep the positions they have always had, but it never SHRINKS (flex: 1 0+ auto, no overflow clip): when the row is short of room fitHeader() sheds or wraps+ something else instead. */+/* Two short lines stacked in the 44px header rather than one long one: side by side+ the pair needed about 300px, and at 1440px wide that is more than the header has+ left once its controls are in, so the second figure was cut off mid-word. Stacked+ it is half that. The labels share one width so the two figures line up. */+header .span-block { display: flex; flex-direction: column; justify-content: center; gap: 1px; flex-shrink: 0; line-height: 1.25; }+header .span-stat { display: flex; align-items: baseline; gap: 6px; white-space: nowrap; }+header .span-stat .span-k { font-family: 'Satoshi', sans-serif; font-size: 10.5px; color: var(--text2); min-width: 7.9em; }+header .span-stat .span-v { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--text); }+/* A plan that is over is still a fact worth stating, but not in the same voice as a+ countdown that is running. */+header .span-stat[data-state="ended"] .span-v,+header .span-stat[data-state="empty"] .span-v { color: var(--text3); }+header .span-stat .span-n { font-family: 'Satoshi', sans-serif; font-size: 10px; color: var(--text3); }+header .span-stat .span-n:empty { display: none; }+header .subject .app-ver { font-family: 'JetBrains Mono', monospace; font-size: 11px; white-space: nowrap; flex-shrink: 0; } header .start-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; font-family: 'Satoshi', sans-serif; } header .start-ctl label { color: var(--text2); font-size: 11px; } header .date-btn { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'JetBrains Mono', monospace; font-size: 11px; padding: 4px 10px; cursor: pointer; transition: border-color 0.15s, color 0.15s; }@@ -354,30 +375,68 @@ header #btnTheme { min-width: 56px; justify-content: center; } the buttons keep their icons and their tooltips, the unit count goes (the axis already shows the span), and the "starts" label goes (its button says a date). With the labels gone the buttons are all one width, so nothing shifts. */-@media (max-width: 1620px) {- .who-name { display: none; }- .who { max-width: none; }- header .subject-meta,- header .start-ctl label,- header .act-btn .btn-label,- header #msStyleLabel { display: none; }- /* Icon-only, but still pinned: the milestone glyph swaps between a bar and a- diamond, which are not the same width, and .actions is right-aligned so that- alone dragged the Fit button sideways. Theme keeps its word rather than- becoming a blank button. */- header #btnMsStyle { min-width: 36px; }-}-/* Narrower still: an abbreviated subtitle is worse than no subtitle. Drop it and- keep the title whole. The full text stays on the header's tooltip. */-@media (max-width: 1180px) {- header .subject-sub { display: none; }+/* ── Fitting the header to its content (0.57.0) ──+ Until 0.56.0 this was a ladder of guessed pixel breakpoints (1620px: drop the button+ words), and the flexible title in the middle absorbed whatever the guesses got+ wrong by shrinking. With the title gone the flexible thing was the Duration block,+ and a review measured it clipped or hidden from 1181 to about 1390px and 1621 to+ about 1745px, worse with a long brand, and the right-hand controls off the edge just+ above the old 1180px wrap. Any fixed breakpoint goes stale the day a button is added.++ So fitHeader() MEASURES. It lays the row out with nothing shed, and if the content+ is wider than the header it sheds one step at a time and measures again, taking the+ first state that fits: the version (it is on the brand tooltip), then the button+ words (the old 1620px set; every button keeps its icon and tooltip), then the long+ labels ("Duration", "Remaining"), and last it wraps onto a second row with the words+ back. The readouts never shrink (.subject is flex: 1 0 auto), so they are never the+ thing that gives. The classes below are the only switches; no rule here names a width. */+header.shed-ver .subject .app-ver { display: none; }+header.shed-labels .who-name,+header.shed-labels .start-ctl label,+header.shed-labels .act-btn .btn-label,+header.shed-labels #msStyleLabel { display: none; }+header.shed-labels .who { max-width: none; }+/* Icon-only, but still pinned: the milestone glyph swaps between a bar and a diamond,+ which are not the same width, and .actions is right-aligned so that alone dragged+ the Fit button sideways. Theme keeps its word rather than becoming a blank button. */+header.shed-labels #btnMsStyle { min-width: 36px; }+header.shed-keys .span-stat .span-k .k-long { display: none; }+header.shed-keys .span-stat .span-k { min-width: 5.6em; }+/* Wrapped: the groups flow onto as many rows as they need, still right-aligned by+ .actions' margin-left: auto, and a group wider than the whole header (the actions on+ a phone) wraps inside itself. Nothing measures the header's height: the chart below+ is a flex child that takes what is left, and every popup is placed from its button. */+header.is-wrapped { flex-wrap: wrap; height: auto; min-height: 44px; padding: 6px 16px; row-gap: 6px; }+header.is-wrapped .vsep { display: none; }+header.is-wrapped .actions { flex-wrap: wrap; justify-content: flex-end; row-gap: 6px; flex-shrink: 1; min-width: 0; }+/* A phone: a little less gutter, and the Duration block sits at its own width rather+ than stretching across a row of its own. */+@media (max-width: 700px) {+ header.is-wrapped { padding: 6px 12px; column-gap: 8px; }+ header.is-wrapped .subject { flex: 0 0 auto; } } /* ── Legend ── */-/* One line, always. The legend and the table's filters are the same kind of- thing (what am I looking at), so stacking them into two bands wasted a row and- read as two unrelated toolbars. nowrap plus a shed ladder below. */-.legend { display: flex; gap: 4px; padding: 8px 16px; border-bottom: 1px solid var(--border-muted); background: var(--surface); align-items: center; flex-wrap: nowrap; flex-shrink: 0; overflow: hidden; }+/* It WRAPS, since 0.57.0. Until then it was one line always (nowrap, overflow hidden),+ which was fine for a three-lane plan and silently cut a twenty-lane one off at the+ pane edge: the chips past the edge could not be seen or clicked, so those lanes+ could not be filtered at all. Now it takes as many lines as the chips need.+ Nothing below it measured its height: the chart and the table are flex children+ that take whatever is left, their sticky axis and headers stick inside their own+ scroll boxes, and the timeline's ResizeObserver re-runs the layout when a resize,+ a text-size step or a lens switch changes how many lines it wraps to.+ row-gap is smaller than the chip padding so a wrapped legend reads as one block+ rather than as a stack of toolbars. The table's filters still join the same line,+ right-aligned by their margin-left: auto, so they sit at the end of the last row. */+.legend { display: flex; gap: 2px 4px; padding: 6px 16px; border-bottom: 1px solid var(--border-muted); background: var(--surface); align-items: center; flex-wrap: wrap; flex-shrink: 0; min-width: 0; }+/* Collapse all leads the line: it acts on the same sections the chips name. A button,+ outlined, so it does not read as one more toggleable chip. Its chevron points the+ way the lane chevrons do, down while any lane is open, right once all are shut. */+.legend-item.collapse-all { background: none; border: 1px solid var(--border); color: var(--text2); padding: 2px 9px 2px 6px; margin-right: 6px; gap: 4px; font-family: 'Satoshi', sans-serif; }+.legend-item.collapse-all:hover { color: var(--text); border-color: var(--accent); background: var(--row-hover); }+.legend-item.collapse-all svg { width: calc(12px * var(--fs)); height: calc(12px * var(--fs)); flex-shrink: 0; transition: transform 0.15s; }+.legend-item.collapse-all.is-all svg { transform: rotate(-90deg); }+.legend-item.collapse-all[disabled] { opacity: 0.4; cursor: default; } .legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text2); font-family: 'Satoshi', sans-serif; padding: 3px 10px; border-radius: 6px; cursor: pointer; transition: opacity 0.15s, background 0.15s; user-select: none; } .legend-item:hover { background: var(--row-hover); } .legend-item.off { opacity: 0.3; }@@ -876,7 +935,7 @@ body.ed-dragging { user-select: none; -webkit-user-select: none; } :root[data-save="blocked"] .save-indicator, :root[data-save="blocked"] .data-warn { bottom: 74px; } /* The version is chrome: present for orientation, never competing with the plan. */-header .subject-meta .app-ver { color: var(--text3); }+header .subject .app-ver { color: var(--text3); } .sel-count { position: fixed; bottom: 16px; left: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--accent); background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 7px; padding: 6px 11px; box-shadow: 0 6px 18px rgba(0,0,0,0.42); z-index: 99998; opacity: 0; transform: translateY(8px); transition: opacity 0.18s ease-out, transform 0.18s ease-out; pointer-events: none; cursor: pointer; } .sel-count.show { opacity: 1; transform: translateY(0); pointer-events: auto; } .sel-count:hover { border-color: var(--accent); color: var(--accent-bright); }@@ -920,6 +979,12 @@ header .subject-meta .app-ver { color: var(--text3); } .legend-item.phase-chip { padding: 3px 6px; } .table-tools input[type="search"] { width: 130px; } }+/* A phone. The legend wraps now (0.57.0), and the table's filters are one more thing+ in it that can be wider than the screen, so they wrap as well instead of pushing+ the page sideways: owner and status on one line, the search and the count below. */+@media (max-width: 700px) {+ .table-tools { flex-wrap: wrap; flex-shrink: 1; min-width: 0; margin-left: 0; padding-left: 0; row-gap: 4px; }+} .table-tools label { font-family: 'Satoshi', sans-serif; font-size: 11px; color: var(--text2); } .table-tools select, .table-tools input[type="search"] { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 8px; outline: none; } .table-tools select:hover, .table-tools select:focus,@@ -1302,6 +1367,10 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va </head> <body> <div class="app">+ <!-- Paper only. The header names the plan once, in the brand block, and puts the title+ and subtitle on its tooltip; a tooltip does not print and appName is often a slug,+ so a printed chart would otherwise not say which plan it is (0.57.0, review). -->+ <div class="print-title" id="printTitle"></div> <header id="hdr"></header> <div class="legend" id="legend"></div> <!-- The dependency-mode info bar used to sit here, between the legend and the chart.@@ -1541,6 +1610,7 @@ document.title = `${project.title} · adom-gantt`; const PREF_KEY = 'adom-gantt:prefs'; // A snapshot opens as its author curated it, so it ignores whatever the viewer happens // to have in localStorage from another chart. The export flips this to true.+// One exception since 0.57.0, the text size: see READER_PREFS below. const SNAPSHOT = false; // Substituted by the server from package.json, so the header cannot drift from // the released version. Opening this file straight off disk leaves the token in@@ -1573,9 +1643,34 @@ function viewDefaults(){ if (v.mode === 'table' || v.mode === 'gantt') d.view = v.mode; return d; }+// Prefs that describe the READER rather than the plan. A snapshot still opens at its+// author's grain, theme, lens and collapse state, but the size of the text is about+// the eyes looking at it, so it is the one thing a snapshot takes from, and gives back+// to, the reader's own storage.+//+// Why this exists (0.57.0). Drew set a larger text size and lost it on every refresh.+// Two causes, both reproduced in Chrome before anything here was changed:+// 1. In an exported snapshot savedPrefs() returned {} outright, so A+ wrote fs to+// localStorage and the next load never read it back. 120% became 100% on EVERY+// refresh, which is the report exactly.+// 2. In the live app the key is one per browser origin, shared by every tab, and+// savePrefs() writes the WHOLE prefs object. A second tab that was opened before+// the size changed still held the old fs, and the next time it saved anything at+// all (a grain change, a collapse, the theme) it wrote that old size back over the+// new one. The refresh then read the stale value. The storage listener in+// wireFontScale() keeps every open tab's fs current, so no tab holds a stale one.+const READER_PREFS = ['fs'];+function storedPrefs(){+ // An array is an object too, and a hand-edited or foreign value that parsed to one+ // 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 {}; }+} function savedPrefs(){- if (SNAPSHOT) return {};- try { return JSON.parse(localStorage.getItem(PREF_KEY)) || {}; } catch { return {}; }+ const all = storedPrefs();+ if (!SNAPSHOT) return all;+ const out = {};+ READER_PREFS.forEach(k => { if (all[k] !== undefined) out[k] = all[k]; });+ return out; } // Seven steps, 85% to 150%. Discrete so the readout is a round number and the // step buttons have somewhere definite to land.@@ -1624,7 +1719,40 @@ function critOn(){ return prefs.critMode !== 'off'; } // dashed rule and its cap asks this rather than testing for 'line', because 'both' // shows them too and a test for one name would have left the caps unplaced there. function msShowsLine(){ return prefs.msStyle !== 'diamond'; }-function savePrefs(){ try { localStorage.setItem(PREF_KEY, JSON.stringify(prefs)); } catch {} }+// A snapshot writes back only the reader prefs, merged into whatever is stored. It+// used to write everything, which it then never read, and on a shared origin (every+// file:// snapshot is one) that overwrote the live app's grain, collapse set and lens+// with whatever the snapshot's author had chosen.+// MERGE ON WRITE (0.57.0, after review). savePrefs used to write the whole prefs+// object, so any open tab overwrote every pref with whatever it happened to hold: the+// text size (cause 2 in READER_PREFS) but equally the collapse set, the theme and the+// grain. A review reproduced it with the collapse set: Collapse all in one tab, toggle+// the theme in another, refresh the first, every lane open again.+//+// Now a save writes only the keys THIS tab changed since it last read or wrote them,+// into a fresh read of what is stored, so a newer value another tab wrote under any+// other key survives. prefBase holds, per key, the JSON this tab last read or wrote.+// A snapshot is the same with the keys limited to the reader prefs.+// Everything as loaded counts as already saved; only what changes from here is written.+// Re-taken at the end of startup (startApp), so the clamps and the in-memory critical+// path drop there are corrections for this page, not choices to write back. prefs is+// declared above this line, so the initialiser can read it.+function prefsAsBase(){ const b={}; Object.keys(prefs).forEach(k=>{ b[k]=JSON.stringify(prefs[k]); }); return b; }+let prefBase=prefsAsBase();+function rebasePrefs(){ prefBase=prefsAsBase(); }+function savePrefs(){+ try {+ const all = storedPrefs();+ let dirty = false;+ Object.keys(prefs).forEach(k => {+ if (SNAPSHOT && READER_PREFS.indexOf(k) < 0) return;+ const j = JSON.stringify(prefs[k]);+ if (j === prefBase[k]) return;+ all[k] = prefs[k]; prefBase[k] = j; dirty = true;+ });+ if (dirty) localStorage.setItem(PREF_KEY, JSON.stringify(all));+ } catch {}+} function applyTheme(){ document.documentElement.setAttribute('data-theme', prefs.theme); } function applyStartupPrefs(){ applyTheme();@@ -1668,6 +1796,51 @@ function applyCollapse(){ // the dashed rule belongs to the milestone, so it goes with it if(r._msLine)r._msLine.classList.toggle('row-collapsed',hide); });+ syncCollapseAllBtn();+}+// ── Collapse or expand every lane at once (0.57.0) ──+//+// The same collapse a lane's chevron does, applied to all of them: the same Set, the+// same prefs.collapsed, the same applyCollapse, so the result persists exactly as a+// hand-collapsed lane does and the table (which reads isCollapsed) agrees with the+// chart. It is a view setting, like the chevron, so it is not an undo step.+//+// The mixed state has one answer: if ANY lane that has rows is open, the button+// collapses everything; only when every such lane is already shut does it expand+// them all. So one press from any state gives a tidy outline, and a second press+// always gets the whole plan back. A lane with no rows has nothing to hide and does+// not get a vote, or an empty section would pin the button on "Collapse all".+const collapseAllBtn=document.createElement('button');+function lanesWithRows(){ return phases.map((p,i)=>i).filter(i=>laneRows(i).length>0); }+function allLanesCollapsed(){ const l=lanesWithRows(); return l.length>0&&l.every(isCollapsed); }+function syncCollapseAllBtn(){+ if(!collapseAllBtn.isConnected)return;+ const lanes=lanesWithRows(), all=allLanesCollapsed();+ collapseAllBtn.classList.toggle('is-all',all);+ collapseAllBtn.querySelector('.ca-label').textContent=all?'Expand all':'Collapse all';+ collapseAllBtn.setAttribute('aria-pressed',all?'true':'false');+ collapseAllBtn.disabled=!lanes.length;+ collapseAllBtn.title=!lanes.length?'No section has any rows to collapse yet.'+ :all?`Expand all ${plural(lanes.length,'section')}. This is your view only, it does not change the plan.`+ :`Collapse all ${plural(lanes.length,'section')} to their headers. This is your view only, it does not change the plan.`;+}+function setAllCollapsed(on){+ collapsed.clear();+ if(on)phases.forEach((p,i)=>collapsed.add(i));+ prefs.collapsed=[...collapsed]; savePrefs();+ applyCollapse();+ layoutMsCaps();sizeTailroom();drawDeps();+ renderTable();+ depHoverResync();+}+function buildLegendCollapseAll(){+ collapseAllBtn.type='button';+ collapseAllBtn.id='btnCollapseAll';+ collapseAllBtn.className='legend-item collapse-all';+ collapseAllBtn.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 9l5 5 5-5"/></svg><span class="ca-label">Collapse all</span>';+ collapseAllBtn.addEventListener('click',()=>setAllCollapsed(!allLanesCollapsed()));+ legendEl.appendChild(collapseAllBtn);+ syncCollapseAllBtn(); } function setCollapsed(ph,on,persist){ if(on)collapsed.add(ph); else collapsed.delete(ph);@@ -1957,10 +2130,25 @@ function addBiz(ds,n){ } // ── Header with import/export buttons ──+// The brand block is the one place the plan is named in the header (0.57.0). A plan+// that sets appName shows that; one that does not shows its own title, because a+// header reading "adom-gantt" names the tool and leaves the reader to guess the plan.+function brandName(){ return project.appName||project.title||'adom-gantt'; }+// The title and subtitle stopped being painted into the header, so the tooltip is+// where they are still read in full: the plan, what it is, and the tool.+function brandTip(){+ const t=project.title||'', parts=[];+ if(project.appName&&t&&t!==project.appName)parts.push(project.appName+': '+t);+ else parts.push(t||project.appName||'adom-gantt');+ if(project.subtitle)parts.push(project.subtitle);+ parts.push('adom-gantt'+(APP_VERSION?' v'+APP_VERSION:''));+ return parts.join('\n');+} function buildHeader(){+$('printTitle').innerHTML = `<div class="pt-name">${esc(project.title||project.appName||'')}</div>${project.subtitle?`<div class="pt-sub">${esc(project.subtitle)}</div>`:''}`; $('hdr').innerHTML = `- <div class="brand" title="adom-gantt${project.appName?', '+esc(project.appName):''}"><svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="3" y="4" width="18" height="2" rx="1" opacity="0.5"/><rect x="5" y="9" width="12" height="2" rx="1"/><rect x="3" y="14" width="16" height="2" rx="1" opacity="0.7"/><rect x="7" y="19" width="10" height="2" rx="1" opacity="0.5"/></svg>- <span class="app-name">${esc(project.appName||'adom-gantt')}</span></div>+ <div class="brand" title="${esc(brandTip())}"><svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="3" y="4" width="18" height="2" rx="1" opacity="0.5"/><rect x="5" y="9" width="12" height="2" rx="1"/><rect x="3" y="14" width="16" height="2" rx="1" opacity="0.7"/><rect x="7" y="19" width="10" height="2" rx="1" opacity="0.5"/></svg>+ <span class="app-name">${esc(brandName())}</span></div> <div class="vsep"></div> <!-- The lens switch is the primary control, so it sits first in the row, anchored to the brand block, which never changes width. It used to live inside .actions,@@ -1975,9 +2163,16 @@ $('hdr').innerHTML = ` <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> </div> <div class="vsep"></div>- <!-- One title, one subtitle. The brand used to repeat the project as an- app-name plus a second subtitle, so a single plan was named three times. -->- <div class="subject" title="${esc(project.title)}${project.subtitle?', '+esc(project.subtitle):''}"><span class="subject-name">${esc(project.title)}</span>${project.subtitle?`<span class="subject-sub">${esc(project.subtitle)}</span>`:''}<span class="subject-meta"><span class="sep">·</span><span id="unitCount">${UNITS} months</span>${APP_VERSION?`<span class="sep">·</span><span class="app-ver" title="adom-gantt ${esc(APP_VERSION)}">v${esc(APP_VERSION)}</span>`:''}</span></div>+ <!-- 0.57.0: the plan is named ONCE, in the brand block. Until 0.56.0 this group+ repeated the project title in bold and the subtitle dimmed after it, so the same+ plan was named twice in 400px of header. What is left here is the plan's clock:+ how long it runs and how much of it is left, both from the real first and last+ dates (syncPlanSpan), so neither moves when the grain picker does. The title and+ subtitle are still data and still on the brand's tooltip and in document.title. -->+ <div class="subject"><div class="span-block">+ <span class="span-stat" id="spanDur"><span class="span-k"><span class="k-long">Project </span>Duration:</span><span class="span-v" id="spanDurV"></span></span>+ <span class="span-stat" id="spanLeft"><span class="span-k"><span class="k-long">Time </span>Remaining:</span><span class="span-v" id="spanLeftV"></span><span class="span-n" id="spanLeftN"></span></span>+ </div>${APP_VERSION?`<span class="app-ver" title="adom-gantt ${esc(APP_VERSION)}">v${esc(APP_VERSION)}</span>`:''}</div> <div class="start-ctl" title="Project start. Changing it moves the whole plan."> <label>starts</label> <button class="date-btn" id="startBtn"></button>@@ -2023,6 +2218,53 @@ $('hdr').innerHTML = ` </div>`:''} </div>`; applyView(); // again, now that the toggle buttons exist+fitHeader();+}++// ── Fit the header to its content ── (the CSS side, and why, is above .legend)+//+// Each step is a set of classes; the first set under which the row's content is no+// wider than the row wins. Measured synchronously, class on, read, class off, inside+// one task, so nothing flickers: the browser paints only the state that was kept.+// "Fits" is two questions, because the readouts must be WHOLE and not merely present:+// nothing in the header runs past its right edge (scrollWidth, which counts content+// that overflows a box that does not clip), and each readout is as wide as its text.+const HDR_STEPS=[[],['shed-ver'],['shed-ver','shed-labels'],['shed-ver','shed-labels','shed-keys'],['shed-ver','shed-labels','is-wrapped']];+const HDR_ALL=['shed-ver','shed-labels','shed-keys','is-wrapped'];+function headerFits(h){+ if(h.scrollWidth>h.clientWidth+0.5)return false;+ const hr=h.getBoundingClientRect();+ for(const el of h.children){ const r=el.getBoundingClientRect(); if(r.width&&(r.right>hr.right+0.5||r.left<hr.left-0.5))return false; }+ for(const st of h.querySelectorAll('.span-stat')){ if(st.scrollWidth>st.clientWidth+0.5)return false; }+ return true;+}+let hdrFitKey='';+function fitHeader(){+ const h=$('hdr'); if(!h||!h.firstElementChild)return;+ let pick=HDR_STEPS[HDR_STEPS.length-1];+ for(const step of HDR_STEPS){+ h.classList.remove(...HDR_ALL); h.classList.add(...step);+ if(headerFits(h)){ pick=step; break; }+ }+ h.classList.remove(...HDR_ALL); h.classList.add(...pick);+ hdrFitKey=pick.join(' ');+}+// Anything that can change what the row holds or how wide it is re-runs the fit: the+// window, a lens switch that hides the chart-only controls, a label that changes+// length (the readouts, the milestone style, the theme), the web fonts arriving.+// The observer watches the groups, not the header, so wrapping (which changes the+// header's height) does not wake it; a group whose size a step changed wakes it once,+// the fit reaches the same answer, and nothing changes again, so it settles.+let hdrFitRaf=0;+function scheduleFitHeader(){ if(hdrFitRaf)return; hdrFitRaf=requestAnimationFrame(()=>{ hdrFitRaf=0; fitHeader(); }); }+function wireHeaderFit(){+ window.addEventListener('resize',scheduleFitHeader);+ if(window.ResizeObserver){+ const ro=new ResizeObserver(scheduleFitHeader);+ [...$('hdr').children].forEach(el=>{ if(!el.classList.contains('subject'))ro.observe(el); });+ const sb=$('hdr').querySelector('.span-block'); if(sb)ro.observe(sb);+ }+ if(document.fonts&&document.fonts.ready)document.fonts.ready.then(scheduleFitHeader); } // The brand has three hues, so the ramp cycles them rather than inventing lighter@@ -3433,7 +3675,7 @@ function flushLogQueue(){ fetch('log',{method:'POST',headers:{'Content-Type':'application/json'},body:logBody(q)}) .catch(()=>{}); }-function scheduleSave(via){diffAndLog(via||'edit');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();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@@ -3679,10 +3921,13 @@ phases.forEach((ph,i)=>legendEl.appendChild(phaseChip(ph,i))); // patched: a rename, a recolour, a new section and a reorder are all the same // operation to it. The chips sit at the FRONT, ahead of the milestone, deps and // today chips and the table tools, which is where they were built.+// Since 0.57.0 the collapse-all button leads the line, so "the front" means straight+// after it rather than firstChild, which would now put the chips ahead of it. function rebuildLegend(){ legendEl.querySelectorAll('.phase-chip').forEach(el=>el.remove());- const first=legendEl.firstChild;+ const first=collapseAllBtn.isConnected?collapseAllBtn.nextSibling:legendEl.firstChild; phases.forEach((ph,i)=>legendEl.insertBefore(phaseChip(ph,i),first));+ syncCollapseAllBtn(); } // A key for the second band colour, and nothing more: the holidays are not something // a reader can switch off, because the schedule underneath them is computed with them@@ -6846,11 +7091,122 @@ function rebuildTimeline(){ relayout(); } +// The start button names the exact first date at EVERY grain. Until 0.56.0 it said+// "Sep 2026" unless the grain was days, which read as though the plan started on the+// first of the month when it started on the 21st: the grain is how the axis is cut,+// not how precisely the plan is known. The month picker behind it at the coarser+// grains still moves the plan by whole months and keeps the day of the month. function syncStartBtn(){ const ps=planStart();- $('startBtn').textContent = isDayMode() ? fmt(ps) : fmt(ps.slice(0,7));- $('startBtn').classList.toggle('shifted', ps!==ORIGINAL_START);- $('unitCount').textContent = `${UNITS} ${(GRAINS[G()]||GRAINS.month).label.toLowerCase()}`;+ const b=$('startBtn');+ if(b){+ // spanDay pads a bare year-month to its first day, so a plan written in months+ // still shows a day here, the same day the Duration tooltip counts from.+ b.textContent = ps ? fmt(spanDay(ps,false)) : 'no dates';+ b.classList.toggle('shifted', !!ps && ps!==ORIGINAL_START);+ b.title = ps ? `The plan's first date, ${fmt(spanDay(ps,false))}, cancelled rows included. Click to move the whole plan.` : 'The plan has no dated tasks or milestones yet.';+ }+ syncPlanSpan();+}++// ── Project Duration and Time Remaining ──+//+// These replace the "25 weeks" readout, which was the number of AXIS CELLS at the+// current grain: it changed when the grain picker did, it counted the padding either+// side of the plan, and on the plan it was reported against it said 25 weeks for+// work the plan itself describes as 16. Both numbers here come from the plan's own+// dates and nothing else, so no view setting can move them.+//+// The span is every task's start and end and every milestone's date, CANCELLED ROWS+// LEFT OUT (changed after review): cancelled work is work that will not happen, and+// Auto-schedule, the critical path and every hours total already leave it out, so a+// cancelled row running to June must not tell the header the plan runs to June. The+// tooltip says how many were left out. If every dated row is cancelled there is no+// live work to measure and both read "none", with the tooltip saying why. The start+// button is the one place cancelled rows still count, because it moves the WHOLE plan.+// Both ends are INCLUSIVE, the way a bar is: a task from Monday to Friday is five+// days, so a plan whose first and last date are the same day runs one day, not zero.+// A date the loader accepts may be a bare year-month ("2026-10"); it counts as the+// first of that month where it opens a span and the last day where it closes one,+// and it is written back out padded, so every figure below is a real day.+function spanDay(ds,atEnd){+ const[y,m,d]=String(ds).split('-').map(Number);+ const dd=d||(atEnd?new Date(y,m,0).getDate():1);+ return `${y}-${String(m).padStart(2,'0')}-${String(dd).padStart(2,'0')}`;+}+function planSpan(){+ let first='', last='', cancelled=0;+ items.forEach(r=>{+ if(r.type==='group')return;+ const ds=r.type==='milestone'?[r.date,r.date]:[r.start,r.end];+ const a=isDateStr(ds[0])?spanDay(ds[0],false):'', b=isDateStr(ds[1])?spanDay(ds[1],true):'';+ if(!a&&!b)return;+ if(isCancelled(r)){ cancelled++; return; }+ if(a&&(!first||dayNum(a)<dayNum(first)))first=a;+ if(b&&(!last||dayNum(b)>dayNum(last)))last=b;+ });+ return {first,last,cancelled};+}+// One readable figure per span. Days while a count of days is still something you+// can picture, weeks and days through half a year (the unit plans are argued in),+// then months and years to one decimal. The exact count is always on the tooltip,+// so the short form is free to round.+function fmtSpan(n){+ if(n<=0)return '0 days';+ if(n===1)return '1 day';+ if(n<14)return n+' days';+ if(n<183){ const w=Math.floor(n/7), d=n%7; return d?`${w}w ${d}d`:`${w}w`; }+ if(n<730)return (Math.round(n/30.4375*10)/10)+' months';+ return (Math.round(n/365.25*10)/10)+' years';+}+function plural(n,w){ return n+' '+w+(n===1?'':'s'); }+// Read at call time, never cached, so a tab left open overnight moves on with the+// calendar (see the minute tick below). Tests pin the page's clock, not this.+function spanToday(){ return isoDay(new Date()); }+let spanPaintedDay='';+function syncPlanSpan(){+ const dv=$('spanDurV'), lv=$('spanLeftV');+ if(!dv||!lv)return;+ const {first,last,cancelled}=planSpan(), today=spanToday();+ spanPaintedDay=today;+ const dur=$('spanDur'), left=$('spanLeft'), note=$('spanLeftN');+ const out=cancelled?` ${plural(cancelled,'cancelled row')} left out.`:'';+ if(note)note.textContent='';+ if(!first||!last){+ dv.textContent=lv.textContent='none';+ dur.title=left.title=cancelled+ ?`Every dated row in the plan is cancelled (${plural(cancelled,'row')}), so there is no live work to measure.`+ :'The plan has no dated tasks or milestones yet, so it has no duration.';+ left.dataset.state=dur.dataset.state='empty';+ return;+ }+ const days=dayNum(last)-dayNum(first)+1;+ dv.textContent=fmtSpan(days);+ dur.dataset.state='ok';+ dur.title=`${plural(days,'calendar day')} (${plural(bizBetween(first,last),'working day')}), from the plan's first date ${fmt(first)} to its last date ${fmt(last)}, both counted.${out}`;+ const T=dayNum(today), F=dayNum(first), L=dayNum(last);+ if(T>L){+ lv.textContent='ended';+ left.dataset.state='ended';+ left.title=`The plan's last date, ${fmt(last)}, was ${plural(T-L,'day')} ago (today is ${fmt(today)}).${out}`;+ return;+ }+ const rem=L-T+1;+ lv.textContent=fmtSpan(rem);+ left.dataset.state=T<F?'not-started':'running';+ // Before the first date Remaining is longer than Duration, which is right (it counts+ // from today, as Drew asked) and would read as a mistake if nothing said why, so the+ // state is on the readout itself and not only in the tooltip.+ if(note&&T<F)note.textContent='not started';+ left.title=`${plural(rem,'calendar day')} (${plural(bizBetween(today,last),'working day')}) from today, ${fmt(today)}, to the plan's last date ${fmt(last)}, both counted.`+ +(T<F?` The plan has not started yet: its first date, ${fmt(first)}, is ${plural(F-T,'day')} away.`:'')+out;+}+// The one thing here that changes with no edit at all is today. A minute tick that+// repaints only when the date has rolled over costs nothing and keeps a tab left open+// overnight honest; coming back to a hidden tab repaints at once.+function wirePlanSpanClock(){+ setInterval(()=>{ if(spanToday()!==spanPaintedDay)syncPlanSpan(); },60000);+ document.addEventListener('visibilitychange',()=>{ if(document.visibilityState==='visible'&&spanToday()!==spanPaintedDay)syncPlanSpan(); }); } // Moving the project start moves the whole plan: every task and milestone shifts by@@ -7832,10 +8188,27 @@ function stepFontScale(d){ const i = FS_STEPS.indexOf(prefs.fs); setFontScale(FS_STEPS[Math.max(0, Math.min(FS_STEPS.length-1, i+d))]); }+// Another tab (or another snapshot) changed the size: take it on here too, without+// writing it back. This is the fix for cause 2 in READER_PREFS: a tab that follows+// the stored size has no stale one to overwrite it with when it next saves. The+// 'storage' event fires in every OTHER same-origin document, never the writer, so+// this cannot loop.+function adoptStoredFontScale(v){+ if(FS_STEPS.indexOf(v)<0 || v===prefs.fs) return;+ prefs.fs = v; prefBase.fs = JSON.stringify(v); applyFontScale(); syncFsCtl();+ renderTable();+ lastPaneW = lastPaneH = lastRelayoutW = -1;+ scheduleRelayout();+} function wireFontScale(){ $('fsDown').addEventListener('click',()=>stepFontScale(-1)); $('fsUp').addEventListener('click',()=>stepFontScale(1)); $('fsPct').addEventListener('click',()=>setFontScale(1));+window.addEventListener('storage',e=>{+ if(e.key!==PREF_KEY || !e.newValue) return;+ let v; try { v = JSON.parse(e.newValue).fs; } catch { return; }+ adoptStoredFontScale(v);+}); syncFsCtl(); } @@ -7920,7 +8293,7 @@ function renderDatePicker(){ ${grid} <div class="dp-foot"> <button class="dp-link${shifted?'':' muted'}" data-act="revert" ${shifted?'':'disabled'}- title="Put the plan back to its original start (${fmt(isDayMode()?ORIGINAL_START:ORIGINAL_START.slice(0,7))})">Revert</button>+ title="Put the plan back to its original start (${fmt(ORIGINAL_START)})">Revert</button> <button class="dp-link" data-act="today">Today</button> </div>`; }@@ -7942,7 +8315,13 @@ datePop.addEventListener('click',e=>{ if(!cell) return; let iso; if(cell.dataset.day!==undefined) iso=isoDay(new Date(dpYear,dpMonth,+cell.dataset.day));- else iso=isoDay(new Date(dpYear,+cell.dataset.month,parseISO(planStart()).getDate()));+ else {+ // The button now shows the exact day (0.57.0), so the month grid has to land on a+ // real one: a plan starting on the 31st moved to February starts on the 28th or+ // 29th, not on the 3rd of March, which is where new Date(y, 1, 31) rolls over to.+ const mo=+cell.dataset.month, last=new Date(dpYear,mo+1,0).getDate();+ iso=isoDay(new Date(dpYear,mo,Math.min(parseISO(planStart()).getDate(),last)));+ } shiftPlanTo(iso,'start-picker'); closeDatePicker(); });@@ -8422,7 +8801,11 @@ function renderTable(){ const rows=tableRows(); const total=items.filter(r=>!isGroup(r)).length; countEl.textContent = rows.length===total ? `${total} items` : `${rows.length} of ${total} items`;- if(!rows.length){tableBody.innerHTML='<tr><td colspan="10" class="table-empty">No items match these filters.</td></tr>';return;}+ if(!rows.length){+ // Collapse all empties the table as surely as a filter does, and "no items match"+ // would send the reader looking at the filters for a cause that is not there.+ const why=allLanesCollapsed()?'Every section is collapsed. Expand all, at the start of the legend, to see the rows.':'No items match these filters.';+ tableBody.innerHTML='<tr><td colspan="10" class="table-empty">'+why+'</td></tr>';return;} tableBody.innerHTML=rows.map(r=>{ const ms=isMs(r), editable=ms||!!r.id; const o=ownerOf(r), st=statusOf(r), dl=deadlineOf(r), sd=ms?'':(r.start||'');@@ -9764,6 +10147,8 @@ function applyView(){ b.classList.toggle('on',on); b.setAttribute('aria-selected',on?'true':'false'); });+ // The chart-only controls come and go with the lens, so the header's fit changes.+ if($('hdr')&&$('hdr').firstElementChild)fitHeader(); } function setView(v){ if(!VIEWS.includes(v))return;@@ -9879,6 +10264,7 @@ if(critOn()&&!planHasDeps()){ } if(critOn())refreshCritical(); if(prefs.view==='analytics')renderAnalytics();+rebasePrefs(); // Say it after the first paint, so the message sits over a chart that clearly // still works rather than over a blank page. if(UNDATED.length){@@ -9927,6 +10313,7 @@ async function init(){ wireGlobalKeys(); captureBaselineSnapshot(); wireSaveFlush();+ buildLegendCollapseAll(); buildLegendPhases(); buildLegendMilestone(); buildLegendDeps();@@ -9961,6 +10348,8 @@ async function init(){ wireWhoChip(); wireNewButton(); wireFontScale();+ wirePlanSpanClock();+ wireHeaderFit(); wireFitButton(); wirePlanCtl(); wireDatePicker();
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.56.0",+ "version": "0.57.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.56.0",+ "version": "0.57.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": {
test/package-lock.jsonadded+913@@ -0,0 +1,913 @@+{+ "name": "adom-gantt-tests",+ "lockfileVersion": 3,+ "requires": true,+ "packages": {+ "": {+ "name": "adom-gantt-tests",+ "dependencies": {+ "puppeteer-core": "^23.11.1"+ }+ },+ "node_modules/@puppeteer/browsers": {+ "version": "2.6.1",+ "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.6.1.tgz",+ "integrity": "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg==",+ "dependencies": {+ "debug": "^4.4.0",+ "extract-zip": "^2.0.1",+ "progress": "^2.0.3",+ "proxy-agent": "^6.5.0",+ "semver": "^7.6.3",+ "tar-fs": "^3.0.6",+ "unbzip2-stream": "^1.4.3",+ "yargs": "^17.7.2"+ },+ "bin": {+ "browsers": "lib/cjs/main-cli.js"+ },+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@tootallnate/quickjs-emscripten": {+ "version": "0.23.0",+ "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",+ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="+ },+ "node_modules/@types/node": {+ "version": "26.6.3",+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz",+ "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==",+ "optional": true,+ "dependencies": {+ "undici-types": "~8.9.0"+ }+ },+ "node_modules/@types/yauzl": {+ "version": "2.10.3",+ "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",+ "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",+ "optional": true,+ "dependencies": {+ "@types/node": "*"+ }+ },+ "node_modules/agent-base": {+ "version": "7.1.4",+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",+ "engines": {+ "node": ">= 14"+ }+ },+ "node_modules/ansi-regex": {+ "version": "5.0.1",+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",+ "engines": {+ "node": ">=8"+ }+ },+ "node_modules/ansi-styles": {+ "version": "4.3.0",+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",+ "dependencies": {+ "color-convert": "^2.0.1"+ },+ "engines": {+ "node": ">=8"+ },+ "funding": {+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"+ }+ },+ "node_modules/ast-types": {+ "version": "0.13.4",+ "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",+ "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",+ "dependencies": {+ "tslib": "^2.0.1"+ },+ "engines": {+ "node": ">=4"+ }+ },+ "node_modules/b4a": {+ "version": "1.9.0",+ "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz",+ "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==",+ "peerDependencies": {+ "react-native-b4a": "*"+ },+ "peerDependenciesMeta": {+ "react-native-b4a": {+ "optional": true+ }+ }+ },+ "node_modules/bare-events": {+ "version": "2.9.2",+ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz",+ "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==",+ "peerDependencies": {+ "bare-abort-controller": "*"+ },+ "peerDependenciesMeta": {+ "bare-abort-controller": {+ "optional": true+ }+ }+ },+ "node_modules/bare-fs": {+ "version": "4.8.2",+ "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.2.tgz",+ "integrity": "sha512-+ZI68KHMUvosXfKbg/UOHK0tbCdRnegbvPEdEcZ3Nd6TetieQsJPRXBRXPdLyy8+3VSEbPXtsumTpEtt78xv9w==",+ "dependencies": {+ "bare-events": "^2.5.4",+ "bare-path": "^3.0.0",+ "bare-stream": "^2.6.4",+ "bare-url": "^2.2.2",+ "fast-fifo": "^1.3.2"+ },+ "engines": {+ "bare": ">=1.28.0"+ },+ "peerDependencies": {+ "bare-buffer": "*"+ },+ "peerDependenciesMeta": {+ "bare-buffer": {+ "optional": true+ }+ }+ },+ "node_modules/bare-path": {+ "version": "3.1.2",+ "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz",+ "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw=="+ },+ "node_modules/bare-stream": {+ "version": "2.13.4",+ "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz",+ "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==",+ "dependencies": {+ "b4a": "^1.8.1",+ "streamx": "^2.25.0",+ "teex": "^1.0.1"+ },+ "peerDependencies": {+ "bare-abort-controller": "*",+ "bare-buffer": "*",+ "bare-events": "*"+ },+ "peerDependenciesMeta": {+ "bare-abort-controller": {+ "optional": true+ },+ "bare-buffer": {+ "optional": true+ },+ "bare-events": {+ "optional": true+ }+ }+ },+ "node_modules/bare-url": {+ "version": "2.5.4",+ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz",+ "integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==",+ "dependencies": {+ "bare-path": "^3.0.0"+ }+ },+ "node_modules/base64-js": {+ "version": "1.5.1",+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",+ "funding": [+ {+ "type": "github",+ "url": "https://github.com/sponsors/feross"+ },+ {+ "type": "patreon",+ "url": "https://www.patreon.com/feross"+ },+ {+ "type": "consulting",+ "url": "https://feross.org/support"+ }+ ]+ },+ "node_modules/basic-ftp": {+ "version": "5.3.1",+ "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",+ "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",+ "engines": {+ "node": ">=10.0.0"+ }+ },+ "node_modules/buffer": {+ "version": "5.7.1",+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",+ "funding": [+ {+ "type": "github",+ "url": "https://github.com/sponsors/feross"+ },+ {+ "type": "patreon",+ "url": "https://www.patreon.com/feross"+ },+ {+ "type": "consulting",+ "url": "https://feross.org/support"+ }+ ],+ "dependencies": {+ "base64-js": "^1.3.1",+ "ieee754": "^1.1.13"+ }+ },+ "node_modules/buffer-crc32": {+ "version": "0.2.13",+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",+ "engines": {+ "node": "*"+ }+ },+ "node_modules/chromium-bidi": {+ "version": "0.11.0",+ "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz",+ "integrity": "sha512-6CJWHkNRoyZyjV9Rwv2lYONZf1Xm0IuDyNq97nwSsxxP3wf5Bwy15K5rOvVKMtJ127jJBmxFUanSAOjgFRxgrA==",+ "dependencies": {+ "mitt": "3.0.1",+ "zod": "3.23.8"+ },+ "peerDependencies": {+ "devtools-protocol": "*"+ }+ },+ "node_modules/cliui": {+ "version": "8.0.1",+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",+ "dependencies": {+ "string-width": "^4.2.0",+ "strip-ansi": "^6.0.1",+ "wrap-ansi": "^7.0.0"+ },+ "engines": {+ "node": ">=12"+ }+ },+ "node_modules/color-convert": {+ "version": "2.0.1",+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",+ "dependencies": {+ "color-name": "~1.1.4"+ },+ "engines": {+ "node": ">=7.0.0"+ }+ },+ "node_modules/color-name": {+ "version": "1.1.4",+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="+ },+ "node_modules/data-uri-to-buffer": {+ "version": "6.0.2",+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",+ "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==",+ "engines": {+ "node": ">= 14"+ }+ },+ "node_modules/debug": {+ "version": "4.4.3",+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",+ "dependencies": {+ "ms": "^2.1.3"+ },+ "engines": {+ "node": ">=6.0"+ },+ "peerDependenciesMeta": {+ "supports-color": {+ "optional": true+ }+ }+ },+ "node_modules/degenerator": {+ "version": "5.0.1",+ "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",+ "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",+ "dependencies": {+ "ast-types": "^0.13.4",+ "escodegen": "^2.1.0",+ "esprima": "^4.0.1"+ },+ "engines": {+ "node": ">= 14"+ }+ },+ "node_modules/devtools-protocol": {+ "version": "0.0.1367902",+ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz",+ "integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg=="+ },+ "node_modules/emoji-regex": {+ "version": "8.0.0",+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="+ },+ "node_modules/end-of-stream": {+ "version": "1.4.5",+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",+ "dependencies": {+ "once": "^1.4.0"+ }+ },+ "node_modules/escalade": {+ "version": "3.2.0",+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",+ "engines": {+ "node": ">=6"+ }+ },+ "node_modules/escodegen": {+ "version": "2.1.0",+ "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",+ "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",+ "dependencies": {+ "esprima": "^4.0.1",+ "estraverse": "^5.2.0",+ "esutils": "^2.0.2"+ },+ "bin": {+ "escodegen": "bin/escodegen.js",+ "esgenerate": "bin/esgenerate.js"+ },+ "engines": {+ "node": ">=6.0"+ },+ "optionalDependencies": {+ "source-map": "~0.6.1"+ }+ },+ "node_modules/esprima": {+ "version": "4.0.1",+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",+ "bin": {+ "esparse": "bin/esparse.js",+ "esvalidate": "bin/esvalidate.js"+ },+ "engines": {+ "node": ">=4"+ }+ },+ "node_modules/estraverse": {+ "version": "5.3.0",+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",+ "engines": {+ "node": ">=4.0"+ }+ },+ "node_modules/esutils": {+ "version": "2.0.3",+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",+ "engines": {+ "node": ">=0.10.0"+ }+ },+ "node_modules/events-universal": {+ "version": "1.0.1",+ "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",+ "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",+ "dependencies": {+ "bare-events": "^2.7.0"+ }+ },+ "node_modules/extract-zip": {+ "version": "2.0.1",+ "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",+ "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",+ "dependencies": {+ "debug": "^4.1.1",+ "get-stream": "^5.1.0",+ "yauzl": "^2.10.0"+ },+ "bin": {+ "extract-zip": "cli.js"+ },+ "engines": {+ "node": ">= 10.17.0"+ },+ "optionalDependencies": {+ "@types/yauzl": "^2.9.1"+ }+ },+ "node_modules/fast-fifo": {+ "version": "1.3.2",+ "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",+ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="+ },+ "node_modules/fd-slicer": {+ "version": "1.1.0",+ "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",+ "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",+ "dependencies": {+ "pend": "~1.2.0"+ }+ },+ "node_modules/get-caller-file": {+ "version": "2.0.5",+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",+ "engines": {+ "node": "6.* || 8.* || >= 10.*"+ }+ },+ "node_modules/get-stream": {+ "version": "5.2.0",+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",+ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",+ "dependencies": {+ "pump": "^3.0.0"+ },+ "engines": {+ "node": ">=8"+ },+ "funding": {+ "url": "https://github.com/sponsors/sindresorhus"+ }+ },+ "node_modules/get-uri": {+ "version": "6.0.5",+ "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz",+ "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==",+ "dependencies": {+ "basic-ftp": "^5.0.2",+ "data-uri-to-buffer": "^6.0.2",+ "debug": "^4.3.4"+ },+ "engines": {+ "node": ">= 14"+ }+ },+ "node_modules/http-proxy-agent": {+ "version": "7.0.2",+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",+ "dependencies": {+ "agent-base": "^7.1.0",+ "debug": "^4.3.4"+ },+ "engines": {+ "node": ">= 14"+ }+ },+ "node_modules/https-proxy-agent": {+ "version": "7.0.6",+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",+ "dependencies": {+ "agent-base": "^7.1.2",+ "debug": "4"+ },+ "engines": {+ "node": ">= 14"+ }+ },+ "node_modules/ieee754": {+ "version": "1.2.1",+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",+ "funding": [+ {+ "type": "github",+ "url": "https://github.com/sponsors/feross"+ },+ {+ "type": "patreon",+ "url": "https://www.patreon.com/feross"+ },+ {+ "type": "consulting",+ "url": "https://feross.org/support"+ }+ ]+ },+ "node_modules/ip-address": {+ "version": "10.7.2",+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",+ "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",+ "engines": {+ "node": ">= 12"+ }+ },+ "node_modules/is-fullwidth-code-point": {+ "version": "3.0.0",+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",+ "engines": {+ "node": ">=8"+ }+ },+ "node_modules/lru-cache": {+ "version": "7.18.3",+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",+ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",+ "engines": {+ "node": ">=12"+ }+ },+ "node_modules/mitt": {+ "version": "3.0.1",+ "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",+ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="+ },+ "node_modules/ms": {+ "version": "2.1.3",+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="+ },+ "node_modules/netmask": {+ "version": "2.1.1",+ "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz",+ "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==",+ "engines": {+ "node": ">= 0.4.0"+ }+ },+ "node_modules/once": {+ "version": "1.4.0",+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",+ "dependencies": {+ "wrappy": "1"+ }+ },+ "node_modules/pac-proxy-agent": {+ "version": "7.2.0",+ "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",+ "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==",+ "dependencies": {+ "@tootallnate/quickjs-emscripten": "^0.23.0",+ "agent-base": "^7.1.2",+ "debug": "^4.3.4",+ "get-uri": "^6.0.1",+ "http-proxy-agent": "^7.0.0",+ "https-proxy-agent": "^7.0.6",+ "pac-resolver": "^7.0.1",+ "socks-proxy-agent": "^8.0.5"+ },+ "engines": {+ "node": ">= 14"+ }+ },+ "node_modules/pac-resolver": {+ "version": "7.0.1",+ "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",+ "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",+ "dependencies": {+ "degenerator": "^5.0.0",+ "netmask": "^2.0.2"+ },+ "engines": {+ "node": ">= 14"+ }+ },+ "node_modules/pend": {+ "version": "1.2.0",+ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="+ },+ "node_modules/progress": {+ "version": "2.0.3",+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",+ "engines": {+ "node": ">=0.4.0"+ }+ },+ "node_modules/proxy-agent": {+ "version": "6.5.0",+ "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",+ "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==",+ "dependencies": {+ "agent-base": "^7.1.2",+ "debug": "^4.3.4",+ "http-proxy-agent": "^7.0.1",+ "https-proxy-agent": "^7.0.6",+ "lru-cache": "^7.14.1",+ "pac-proxy-agent": "^7.1.0",+ "proxy-from-env": "^1.1.0",+ "socks-proxy-agent": "^8.0.5"+ },+ "engines": {+ "node": ">= 14"+ }+ },+ "node_modules/proxy-from-env": {+ "version": "1.1.0",+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="+ },+ "node_modules/pump": {+ "version": "3.0.4",+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",+ "dependencies": {+ "end-of-stream": "^1.1.0",+ "once": "^1.3.1"+ }+ },+ "node_modules/puppeteer-core": {+ "version": "23.11.1",+ "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-23.11.1.tgz",+ "integrity": "sha512-3HZ2/7hdDKZvZQ7dhhITOUg4/wOrDRjyK2ZBllRB0ZCOi9u0cwq1ACHDjBB+nX+7+kltHjQvBRdeY7+W0T+7Gg==",+ "dependencies": {+ "@puppeteer/browsers": "2.6.1",+ "chromium-bidi": "0.11.0",+ "debug": "^4.4.0",+ "devtools-protocol": "0.0.1367902",+ "typed-query-selector": "^2.12.0",+ "ws": "^8.18.0"+ },+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/require-directory": {+ "version": "2.1.1",+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",+ "engines": {+ "node": ">=0.10.0"+ }+ },+ "node_modules/semver": {+ "version": "7.8.5",+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",+ "bin": {+ "semver": "bin/semver.js"+ },+ "engines": {+ "node": ">=10"+ }+ },+ "node_modules/smart-buffer": {+ "version": "4.2.0",+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",+ "engines": {+ "node": ">= 6.0.0",+ "npm": ">= 3.0.0"+ }+ },+ "node_modules/socks": {+ "version": "2.8.10",+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",+ "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==",+ "dependencies": {+ "ip-address": "^10.1.1",+ "smart-buffer": "^4.2.0"+ },+ "engines": {+ "node": ">= 10.0.0",+ "npm": ">= 3.0.0"+ }+ },+ "node_modules/socks-proxy-agent": {+ "version": "8.0.5",+ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",+ "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",+ "dependencies": {+ "agent-base": "^7.1.2",+ "debug": "^4.3.4",+ "socks": "^2.8.3"+ },+ "engines": {+ "node": ">= 14"+ }+ },+ "node_modules/source-map": {+ "version": "0.6.1",+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",+ "optional": true,+ "engines": {+ "node": ">=0.10.0"+ }+ },+ "node_modules/streamx": {+ "version": "2.28.1",+ "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz",+ "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==",+ "dependencies": {+ "events-universal": "^1.0.0",+ "fast-fifo": "^1.3.2",+ "text-decoder": "^1.1.0"+ }+ },+ "node_modules/string-width": {+ "version": "4.2.3",+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",+ "dependencies": {+ "emoji-regex": "^8.0.0",+ "is-fullwidth-code-point": "^3.0.0",+ "strip-ansi": "^6.0.1"+ },+ "engines": {+ "node": ">=8"+ }+ },+ "node_modules/strip-ansi": {+ "version": "6.0.1",+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",+ "dependencies": {+ "ansi-regex": "^5.0.1"+ },+ "engines": {+ "node": ">=8"+ }+ },+ "node_modules/tar-fs": {+ "version": "3.1.3",+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz",+ "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==",+ "dependencies": {+ "pump": "^3.0.0",+ "tar-stream": "^3.1.5"+ },+ "optionalDependencies": {+ "bare-fs": "^4.0.1",+ "bare-path": "^3.0.0"+ }+ },+ "node_modules/tar-stream": {+ "version": "3.2.1",+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz",+ "integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==",+ "dependencies": {+ "b4a": "^1.6.4",+ "bare-fs": "^4.5.5",+ "fast-fifo": "^1.2.0",+ "streamx": "^2.15.0"+ }+ },+ "node_modules/teex": {+ "version": "1.0.1",+ "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",+ "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",+ "dependencies": {+ "streamx": "^2.12.5"+ }+ },+ "node_modules/text-decoder": {+ "version": "1.2.7",+ "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",+ "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",+ "dependencies": {+ "b4a": "^1.6.4"+ }+ },+ "node_modules/through": {+ "version": "2.3.8",+ "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",+ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="+ },+ "node_modules/tslib": {+ "version": "2.8.1",+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="+ },+ "node_modules/typed-query-selector": {+ "version": "2.12.3",+ "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.3.tgz",+ "integrity": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg=="+ },+ "node_modules/unbzip2-stream": {+ "version": "1.4.3",+ "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz",+ "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==",+ "dependencies": {+ "buffer": "^5.2.1",+ "through": "^2.3.8"+ }+ },+ "node_modules/undici-types": {+ "version": "8.9.0",+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",+ "optional": true+ },+ "node_modules/wrap-ansi": {+ "version": "7.0.0",+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",+ "dependencies": {+ "ansi-styles": "^4.0.0",+ "string-width": "^4.1.0",+ "strip-ansi": "^6.0.0"+ },+ "engines": {+ "node": ">=10"+ },+ "funding": {+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"+ }+ },+ "node_modules/wrappy": {+ "version": "1.0.2",+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="+ },+ "node_modules/ws": {+ "version": "8.22.0",+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",+ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",+ "engines": {+ "node": ">=10.0.0"+ },+ "peerDependencies": {+ "bufferutil": "^4.0.1",+ "utf-8-validate": ">=5.0.2"+ },+ "peerDependenciesMeta": {+ "bufferutil": {+ "optional": true+ },+ "utf-8-validate": {+ "optional": true+ }+ }+ },+ "node_modules/y18n": {+ "version": "5.0.8",+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",+ "engines": {+ "node": ">=10"+ }+ },+ "node_modules/yargs": {+ "version": "17.7.3",+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",+ "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",+ "dependencies": {+ "cliui": "^8.0.1",+ "escalade": "^3.1.1",+ "get-caller-file": "^2.0.5",+ "require-directory": "^2.1.1",+ "string-width": "^4.2.3",+ "y18n": "^5.0.5",+ "yargs-parser": "^21.1.1"+ },+ "engines": {+ "node": ">=12"+ }+ },+ "node_modules/yargs-parser": {+ "version": "21.1.1",+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",+ "engines": {+ "node": ">=12"+ }+ },+ "node_modules/yauzl": {+ "version": "2.10.0",+ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",+ "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",+ "dependencies": {+ "buffer-crc32": "~0.2.3",+ "fd-slicer": "~1.1.0"+ }+ },+ "node_modules/zod": {+ "version": "3.23.8",+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",+ "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",+ "funding": {+ "url": "https://github.com/sponsors/colinhacks"+ }+ }+ }+}
test/package.jsonadded+1@@ -0,0 +1 @@+{"name":"adom-gantt-tests","private":true,"type":"module","dependencies":{"puppeteer-core":"^23.11.1"}}
test/ui_test.mjsadded+971@@ -0,0 +1,971 @@+#!/usr/bin/env node+// adom-gantt browser suite (0.57.0). Real Chrome, real server, seeded temp data.+//+// node test/ui_test.mjs everything: flows at 1440, viewport matrix, coverage gate, leak watch+// node test/ui_test.mjs --keep also leave screenshots and the temp dir (test/out/ + printed path)+// node test/ui_test.mjs --plan <p> additionally run the header/legend/duration checks against a+// real plan file (it is COPIED to a temp dir first; the server+// writes sidecars beside whatever file it is given)+//+// Built to ~/.claude/skills/app-testing: the suite spawns its own server on a scratch+// port against a temp copy of seeded data, fails on ANY page error on any page, asserts+// the effect of each behaviour rather than the presence of an element, walks every+// state for a control-coverage manifest (no orphans, no fiction, click every safe+// control), renders a phone and two desktop widths, pins today's date in the page,+// always cleans up, and exits non-zero on any failure.+//+// Layers NOT built here: the protocol/integration layer (this app has one process, the+// server, and its API is exercised through the UI), golden-image visual regression+// (the container has no copies of the app's web fonts, so pixels would drift from any+// real browser's and a baseline would bless a fallback face), and the Ralph pass,+// which is a judgement loop run by a person or an agent over the --keep screenshots.+import puppeteer from './node_modules/puppeteer-core/lib/esm/puppeteer/puppeteer-core.js';+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 { join, dirname } from 'node:path';+import { fileURLToPath } from 'node:url';++const HERE = dirname(fileURLToPath(import.meta.url));+const ROOT = dirname(HERE);+const argv = process.argv.slice(2);+const KEEP = argv.includes('--keep');+const REAL_PLAN = argv.includes('--plan') ? argv[argv.indexOf('--plan') + 1] : null;+const OUT = join(HERE, 'out');+const TMP = mkdtempSync(join(tmpdir(), 'adom-gantt-ui-'));+if (KEEP) mkdirSync(OUT, { recursive: true });++// ── Chrome: the Puppeteer-managed one, newest build ──+const CHROME_DIR = join(process.env.HOME || '/home/adom', '.cache/puppeteer/chrome');+const CHROME = process.env.CHROME_PATH || (() => {+ const dirs = readdirSync(CHROME_DIR).filter(d => d.startsWith('linux-'))+ .sort((a, b) => a.split('-')[1].localeCompare(b.split('-')[1], undefined, { numeric: true }));+ return join(CHROME_DIR, dirs.at(-1), 'chrome-linux64', 'chrome');+})();++// ── Results ──+let pass = 0, fail = 0;+const failures = [];+let section = '';+function check(name, ok, detail = '') {+ const label = section ? `[${section}] ${name}` : name;+ if (ok) { pass++; console.log(` ok ${label}`); }+ else { fail++; failures.push(`${label}${detail ? ' :: ' + detail : ''}`); console.log(` FAIL ${label}${detail ? '\n ' + detail : ''}`); }+}+const sleep = ms => new Promise(r => setTimeout(r, ms));++// ── Seeded plan: 20 sections, first date 21-Sep-2026, last date 14-Jan-2027 ──+// Synthetic on purpose, so the suite ships without anyone's real plan in it and every+// expected figure below can be worked out by hand. Same shape as the instaPCB launch+// plan Drew reported the legend and header problems against: 20 phases, ~110 rows.+const FIRST = '2026-09-21', LAST = '2027-01-14';+const TODAY = '2026-09-29';+function addDaysISO(ds, n) { const [y, m, d] = ds.split('-').map(Number); const t = new Date(Date.UTC(y, m - 1, d + n)); return t.toISOString().slice(0, 10); }+function seedPlan({ appName = 'launch-demo', title = 'Launch Demo: Jan 11, 2027' } = {}) {+ const groups = ['Launch', 'Tech', 'Process', 'Ops'];+ const phases = [], items = [];+ let uid = 1;+ const U = () => String(uid++).padStart(5, '0');+ for (let p = 0; p < 20; p++) {+ phases.push({ name: `${groups[Math.floor(p / 5)]} · Workstream ${String.fromCharCode(65 + p)} long name`, start: '2026-09', end: '2027-01' });+ // five tasks per lane, staggered, all inside [FIRST, LAST - 7]+ for (let t = 0; t < 5; t++) {+ const s = addDaysISO(FIRST, 7 + p * 3 + t * 5);+ items.push({ type: 'task', id: `p${p}t${t}`, name: `Lane ${p + 1} task ${t + 1}`, start: s, end: addDaysISO(s, 4), phase: p, status: 'not-started', owner: ['Drew', 'John', 'Ana'][t % 3], uid: U() });+ }+ items.push({ type: 'milestone', name: `Lane ${p + 1} gate`, date: addDaysISO(FIRST, 30 + p * 2), phase: p, uid: U() });+ }+ // The plan's first date is a milestone in lane 1, the last is a task in lane 20.+ items.unshift({ type: 'milestone', name: 'Kickoff', date: FIRST, phase: 0, uid: U() });+ // "gate-a" ends Tue 12-Jan; "finale" (the last row of the plan) starts Mon 11-Jan+ // and waits on it, so Auto-schedule pushes finale later and moves the plan's end.+ items.push({ type: 'task', id: 'gate-a', name: 'Final gate prep', start: '2027-01-06', end: '2027-01-12', phase: 19, status: 'not-started', owner: 'Drew', uid: U() });+ items.push({ type: 'task', id: 'finale', name: 'Launch finale', start: '2027-01-11', end: LAST, phase: 19, status: 'not-started', owner: 'Drew', deps: ['gate-a'], uid: U() });+ // Cancelled work that would stretch the plan to June if it were counted. It is not:+ // the readouts leave cancelled rows out, the way Auto-schedule and the hours do.+ items.push({ type: 'task', id: 'dropped', name: 'Dropped export programme', start: '2027-03-01', end: '2027-06-30', phase: 10, status: 'cancelled', owner: 'Ana', uid: U() });+ return {+ project: { title, subtitle: 'Sixteen weeks from proof of concept to launch, one section per workstream', ...(appName ? { appName } : {}),+ view: { mode: 'gantt', grain: 'week', milestones: 'diamonds', today: true, labelWidth: 300 }, nextUid: uid },+ phases, items,+ };+}++// ── The oracle for the two readouts. Deliberately NOT imported from the page: if the+// app's formatter drifted, a test that asked the app what the answer is would drift+// with it. Inclusive day counts, the same rule a bar follows.+const dayNum = ds => { const [y, m, d] = ds.split('-').map(Number); return Math.round(Date.UTC(y, m - 1, d) / 86400000); };+function spanText(n) {+ if (n <= 0) return '0 days';+ if (n === 1) return '1 day';+ if (n < 14) return n + ' days';+ if (n < 183) { const w = Math.floor(n / 7), d = n % 7; return d ? `${w}w ${d}d` : `${w}w`; }+ if (n < 730) return (Math.round(n / 30.4375 * 10) / 10) + ' months';+ return (Math.round(n / 365.25 * 10) / 10) + ' years';+}+const MN = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];+const fmtDate = ds => { const [y, m, d] = ds.split('-').map(Number); return `${String(d).padStart(2, '0')}-${MN[m - 1]}-${y}`; };+// The app keeps its state inside a closure (one async IIFE), so the page has no+// globals to ask. The oracle reads the plan the SERVER holds instead, through the+// same export an AI or a person would use, after the edit's debounced save has+// landed. That makes every live-update check end to end: edit in the UI, saved to+// the sidecar, re-read, and compared with what the header says.+async function exportPlan(url) { return (await fetch(url + 'export/json')).json(); }+async function savedPlan(url, { minWait = 1100 } = {}) {+ await sleep(minWait);+ let prev = JSON.stringify(await exportPlan(url));+ for (let i = 0; i < 20; i++) {+ await sleep(250);+ const cur = JSON.stringify(await exportPlan(url));+ if (cur === prev) return JSON.parse(cur);+ prev = cur;+ }+ return JSON.parse(prev);+}+function spanOf(plan) {+ let f = '', l = '';+ const ok = s => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s);+ (plan.items || []).forEach(r => { if (r.type === 'group' || /^cancel/i.test(r.status || '')) return;+ (r.type === 'milestone' ? [r.date] : [r.start, r.end]).forEach(d => { if (!ok(d)) return; if (!f || d < f) f = d; if (!l || d > l) l = d; }); });+ return { first: f, last: l, n: (plan.items || []).filter(r => r.type !== 'group').length };+}+async function readouts(page) {+ return page.evaluate(() => ({+ dur: document.getElementById('spanDurV')?.textContent || null,+ left: document.getElementById('spanLeftV')?.textContent || null,+ durTip: document.getElementById('spanDur')?.title || '',+ leftTip: document.getElementById('spanLeft')?.title || '',+ leftState: document.getElementById('spanLeft')?.dataset.state || '',+ start: document.getElementById('startBtn')?.textContent || null,+ }));+}+// The readouts are right if they say what the oracle says about the plan's CURRENT dates.+let SRV = '';+async function expectReadouts(page, today, what) {+ const plan = await savedPlan(SRV);+ const { first, last, n } = spanOf(plan);+ const r = await readouts(page);+ const wantDur = spanText(dayNum(last) - dayNum(first) + 1);+ const wantLeft = dayNum(today) > dayNum(last) ? 'ended' : spanText(dayNum(last) - dayNum(today) + 1);+ check(`${what}: Project Duration = ${wantDur}`, r.dur === wantDur, `got ${r.dur} (first ${first}, last ${last})`);+ check(`${what}: Time Remaining = ${wantLeft}`, r.left === wantLeft, `got ${r.left}`);+ check(`${what}: start button = ${fmtDate(first)}`, r.start === fmtDate(first), `got ${r.start}`);+ return { first, last, n, ...r };+}++// ── Server ──+const servers = [];+let nextPort = 8960;+// A port is used only if nothing is listening on it, and the server that answers+// /health must be the one this suite spawned: if the spawn died on EADDRINUSE, some+// other process would be answering and the suite would be testing that instead.+async function freePort() {+ for (;;) {+ const p = nextPort++;+ const ok = await new Promise(res => { const t = net.createServer(); t.once('error', () => res(false)); t.listen(p, '127.0.0.1', () => t.close(() => res(true))); });+ if (ok) return p;+ if (nextPort > 8999) throw new Error('no free scratch port in 8960..8999');+ }+}+async function startServer(plan, name) {+ const dir = mkdtempSync(join(TMP, name + '-'));+ const file = join(dir, 'plan.json');+ if (typeof plan === 'string') writeFileSync(file, readFileSync(plan)); else writeFileSync(file, JSON.stringify(plan, null, 1));+ const port = await freePort();+ const proc = spawn(process.execPath, [join(ROOT, 'server.js'), '--data', file, '--port', String(port), '--no-auth'], { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });+ let log = ''; proc.stdout.on('data', d => log += d); proc.stderr.on('data', d => log += d);+ const url = `http://127.0.0.1:${port}/`;+ for (let i = 0; i < 100; i++) {+ if (proc.exitCode !== null) throw new Error(`server ${name} exited (${proc.exitCode}): ${log}`);+ try { const r = await fetch(url + 'health'); if (r.ok && log.includes(`:${port}`)) break; } catch {}+ await sleep(100);+ if (i === 99) throw new Error(`server ${name} did not come up: ${log}`);+ }+ const s = { proc, url, dir, file, port, log: () => log };+ servers.push(s);+ return s;+}++// ── Pages: error gate, pinned clock, input modality ──+const pageErrors = [];+async function openPage(browser, url, { w = 1440, h = 900, mobile = false, today = TODAY, clear = true, ctx = null } = {}) {+ const page = await (ctx || browser).newPage();+ const tag = `${w}px ${url.replace(/^.*\/\/[^/]+/, '').slice(0, 40) || '/'}`;+ page.on('pageerror', e => pageErrors.push(`[${tag}] pageerror: ${e.message}`));+ page.on('console', m => { if (m.type() === 'error') {+ const t = m.text();+ // Fonts come from Google and there is no network here; a failed font fetch is the+ // container, not the app. Everything else is an error.+ if (/fonts\.(googleapis|gstatic)\.com|net::ERR_(NAME_NOT_RESOLVED|INTERNET_DISCONNECTED|CONNECTION_REFUSED)/.test(t)) return;+ if (/Failed to load resource/.test(t) && !/127\.0\.0\.1/.test(t)) return;+ pageErrors.push(`[${tag}] console: ${t.slice(0, 200)}`);+ } });+ 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.+ const cdp = await page.createCDPSession();+ await cdp.send('Emulation.setEmulatedMedia', { features: mobile+ ? [{ name: 'any-hover', value: 'none' }, { name: 'any-pointer', value: 'coarse' }, { name: 'hover', value: 'none' }, { name: 'pointer', value: 'coarse' }]+ : [{ name: 'any-hover', value: 'hover' }, { name: 'any-pointer', value: 'fine' }, { name: 'hover', value: 'hover' }, { name: 'pointer', value: 'fine' }] });+ // Today is pinned: the Date constructor with no arguments and Date.now() both read a+ // clock offset to noon on `today`, and still tick, so timers behave.+ await page.evaluateOnNewDocument(iso => {+ const [y, m, d] = iso.split('-').map(Number);+ const Real = Date; let off = new Real(y, m - 1, d, 12, 0, 0).getTime() - Real.now();+ // The suite's own knob (not the app's): move the pinned clock on, to test rollover.+ Object.defineProperty(window, '__advanceClockDays', { value: n => { off += n * 86400000; } });+ function PinnedDate(...a) {+ if (!new.target) return new Real(Real.now() + off).toString();+ return a.length ? new Real(...a) : new Real(Real.now() + off);+ }+ PinnedDate.prototype = Real.prototype;+ PinnedDate.now = () => Real.now() + off; PinnedDate.parse = Real.parse; PinnedDate.UTC = Real.UTC;+ globalThis.Date = PinnedDate;+ }, today);+ await page.goto(url, { waitUntil: 'networkidle0' });+ if (clear) { await page.evaluate(() => { try { localStorage.clear(); } catch {} }); await page.reload({ waitUntil: 'networkidle0' }); }+ await settle(page);+ return page;+}+// Two frames, or 300ms if the tab is in the background (a hidden tab gets no frames).+async function settle(page, ms = 250) { await sleep(ms); await page.evaluate(() => new Promise(r => { setTimeout(r, 300); requestAnimationFrame(() => requestAnimationFrame(r)); })); }+async function shot(page, name) { if (KEEP) await page.screenshot({ path: join(OUT, name + '.png') }); }+const fsPct = page => page.$eval('#fsPct', e => e.textContent);+async function setGrain(page, g) { await page.select('#grainSel', g); await settle(page, 200); }+async function view(page, v) { await page.click(`.vt-btn[data-view="${v}"]`); await settle(page, 200); }++// ── Geometry helpers ──+async function legendGeometry(page) {+ return page.evaluate(() => {+ const L = document.getElementById('legend').getBoundingClientRect();+ const chips = [...document.querySelectorAll('#legend .phase-chip')].map(c => {+ const r = c.getBoundingClientRect(), cx = r.left + r.width / 2, cy = r.top + r.height / 2;+ const hit = document.elementFromPoint(cx, cy);+ return { top: Math.round(r.top), left: r.left, right: r.right, bottom: r.bottom, w: r.width, clickable: !!hit && (hit === c || c.contains(hit)) };+ });+ const cw = document.querySelector('.chart-wrap').getBoundingClientRect();+ return { L: { top: L.top, bottom: L.bottom, left: L.left, right: L.right, h: L.height }, chips, lines: new Set(chips.map(c => c.top)).size,+ chartTop: cw.top, vw: innerWidth };+ });+}+// Scroll the chart down and measure the three things that stick: the two axis rows and+// the pinned section header. Each must sit exactly where the one above it ends.+async function stickyGeometry(page) {+ return page.evaluate(async () => {+ const tl = document.getElementById('timeline'), lc = document.getElementById('labels');+ tl.scrollTop = 900; lc.scrollTop = 900;+ await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));+ const T = tl.getBoundingClientRect();+ const m = document.querySelector('.m-row').getBoundingClientRect();+ const q = document.querySelector('.q-row').getBoundingClientRect();+ const lm = document.querySelector('.label-h-m').getBoundingClientRect();+ // the pinned header is the group row whose top is at the sticky offset+ const groups = [...document.querySelectorAll('.label-row.group')].map(g => g.getBoundingClientRect());+ const pinned = groups.filter(g => g.top <= lm.bottom + 1 && g.bottom > lm.bottom).sort((a, b) => b.top - a.top)[0] || null;+ const fs = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--fs')) || 1;+ const scrolled = tl.scrollTop;+ tl.scrollTop = 0; lc.scrollTop = 0;+ return { tTop: T.top, qTop: q.top, mBottom: m.bottom, lmBottom: lm.bottom, pinnedTop: pinned ? pinned.top : null, fs, scrolled };+ });+}+async function assertSticky(page, what) {+ const g = await stickyGeometry(page);+ check(`${what}: chart scrolled for the sticky check`, g.scrolled > 100, `scrollTop ${g.scrolled}`);+ check(`${what}: axis stays at the top of the chart pane`, Math.abs(g.qTop - g.tTop) <= 1.5, `q.top ${g.qTop} vs pane ${g.tTop}`);+ check(`${what}: axis height = 46px x text size`, Math.abs((g.mBottom - g.tTop) - 46 * g.fs) <= 2, `axis ${g.mBottom - g.tTop} fs ${g.fs}`);+ check(`${what}: pinned section header meets the axis`, g.pinnedTop !== null && Math.abs(g.pinnedTop - g.lmBottom) <= 2, `header ${g.pinnedTop} axis ${g.lmBottom}`);+}+async function assertNoSidewaysScroll(page, what) {+ const o = await page.evaluate(() => {+ const d = document.documentElement;+ const out = [...document.querySelectorAll('#hdr button, #hdr select, #legend .legend-item, #legend select, #legend input, #legend button')]+ .filter(e => e.checkVisibility && e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }))+ .map(e => ({ e, r: e.getBoundingClientRect() }))+ .filter(x => x.r.width > 0 && (x.r.right > innerWidth + 0.5 || x.r.left < -0.5))+ .map(x => (x.e.id || x.e.className || x.e.tagName) + ' @' + Math.round(x.r.left) + '..' + Math.round(x.r.right));+ return { over: d.scrollWidth - d.clientWidth, bodyOver: document.body.scrollWidth - document.body.clientWidth, off: out };+ });+ check(`${what}: page does not scroll sideways`, o.over <= 0 && o.bodyOver <= 0, `doc +${o.over}px body +${o.bodyOver}px`);+ check(`${what}: every header and legend control is inside the window`, o.off.length === 0, o.off.slice(0, 6).join(' | '));+}++// ────────────────────────────────────────────────────────────────────────────+async function main() {+ const browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new',+ args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--hide-scrollbars'] });+ try {+ const plan = seedPlan();+ const S = await startServer(plan, 'plan20');+ SRV = S.url;++ // ── 3. Header declutter ──+ section = 'header';+ let page = await openPage(browser, S.url);+ const hdr = await page.evaluate(() => ({+ brand: document.querySelector('#hdr .brand .app-name').textContent,+ brandTip: document.querySelector('#hdr .brand').title,+ name: !!document.querySelector('.subject-name'), sub: !!document.querySelector('.subject-sub'),+ unitCount: !!document.getElementById('unitCount'),+ text: document.getElementById('hdr').innerText, docTitle: document.title,+ }));+ check('brand block shows the plan appName', hdr.brand === plan.project.appName, hdr.brand);+ check('no bold title (.subject-name) in the header', !hdr.name);+ check('no dimmed description (.subject-sub) in the header', !hdr.sub);+ check('the title and subtitle are not painted anywhere in the header', !hdr.text.includes(plan.project.title) && !hdr.text.includes(plan.project.subtitle));+ check('brand tooltip still carries the title and the subtitle', hdr.brandTip.includes(plan.project.title) && hdr.brandTip.includes(plan.project.subtitle), hdr.brandTip);+ check('document.title still names the plan', hdr.docTitle.includes(plan.project.title), hdr.docTitle);+ check('the old cell count (#unitCount) is gone', !hdr.unitCount);+ check('header text has no "weeks" cell count', !/\b\d+ (weeks|months|days|quarters|years)\b/.test(hdr.text), hdr.text.replace(/\s+/g, ' ').slice(0, 160));+ await shot(page, 'desktop-1440-header-legend');++ // ── 4 + 5. Exact start date and the two readouts, at every grain ──+ section = 'dates';+ const base = await expectReadouts(page, TODAY, 'on load');+ check('on load: duration is 16w 4d (116 days, 21-Sep-2026..14-Jan-2027)', base.dur === '16w 4d', base.dur);+ check('on load: remaining is 15w 3d (108 days from 29-Sep-2026)', base.left === '15w 3d', base.left);+ check('duration tooltip states both exact dates', base.durTip.includes('21-Sep-2026') && base.durTip.includes('14-Jan-2027') && base.durTip.includes('116 calendar days'), base.durTip);+ check('remaining tooltip states today and the end date', base.leftTip.includes('29-Sep-2026') && base.leftTip.includes('14-Jan-2027'), base.leftTip);+ check('the cancelled row to 30-Jun-2027 is left out, and the tooltip says so', base.dur === '16w 4d' && /1 cancelled row left out/.test(base.durTip), base.durTip);+ check('while running there is no "not started" note', await page.$eval('#spanLeftN', e => e.textContent) === '');+ for (const g of ['day', 'week', 'month', 'quarter', 'year']) {+ await setGrain(page, g);+ const r = await readouts(page);+ const cells = await page.$$eval('.m-row > *', e => e.length);+ check(`grain ${g}: start shows the exact date 21-Sep-2026`, r.start === '21-Sep-2026', r.start);+ check(`grain ${g}: duration and remaining do not move`, r.dur === base.dur && r.left === base.left, `${r.dur} / ${r.left}`);+ check(`grain ${g}: the axis still redraws (${cells} cells)`, cells > 0);+ }+ await setGrain(page, 'week');++ // Start-date picker: opens, and a month pick lands on an exact date.+ section = 'start picker';+ await page.click('#startBtn'); await settle(page, 100);+ check('the start-date picker opens', await page.$eval('#datePop', e => e.classList.contains('show')));+ await page.click('#datePop .dp-cell[data-month="9"]'); await settle(page);+ let r = await expectReadouts(page, TODAY, 'after moving the start to October');+ check('start moved to 21-Oct-2026 (exact day kept)', r.start === '21-Oct-2026', r.start);+ check('a whole-plan shift keeps the duration', r.dur === base.dur, r.dur);+ check('a whole-plan shift moves the remaining time', r.left !== base.left, r.left);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);+ r = await expectReadouts(page, TODAY, 'undo of the start shift');+ check('undo puts the start back', r.start === '21-Sep-2026' && r.dur === base.dur && r.left === base.left, JSON.stringify(r));++ // Drag the last bar's right edge two weeks later.+ section = 'live: drag';+ const finaleBox = async () => page.evaluate(() => {+ const b = document.querySelector('.bar[data-id="finale"]'); b.scrollIntoView({ block: 'center', inline: 'center' });+ const h = b.querySelector('.drag-handle.right') || b; const r = h.getBoundingClientRect();+ return { x: r.left + r.width / 2, y: r.top + r.height / 2, col: document.querySelector('.m-row > *').getBoundingClientRect().width };+ });+ let fb = await finaleBox(); await settle(page, 150); fb = await finaleBox();+ await page.mouse.move(fb.x, fb.y); await page.mouse.down();+ for (let i = 1; i <= 8; i++) { await page.mouse.move(fb.x + fb.col * 2 * i / 8, fb.y); await sleep(20); }+ await page.mouse.up(); await settle(page, 300);+ const afterDrag = await expectReadouts(page, TODAY, 'after dragging the last bar later');+ check('the drag really moved the end of the plan', afterDrag.last > LAST, afterDrag.last);+ check('duration grew with it', afterDrag.dur !== base.dur, afterDrag.dur);+ section = 'live: undo/redo';+ await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);+ r = await expectReadouts(page, TODAY, 'undo');+ check('undo restores 16w 4d', r.dur === base.dur, r.dur);+ await page.keyboard.down('Control'); await page.keyboard.down('Shift'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Shift'); await page.keyboard.up('Control'); await settle(page);+ r = await expectReadouts(page, TODAY, 'redo');+ check('redo brings the dragged end back', r.dur === afterDrag.dur, r.dur);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);++ // Auto-schedule pushes "finale" after "gate-a", which moves the plan's last date.+ section = 'live: auto-schedule';+ await page.click('#btnAuto');+ await page.waitForFunction(() => !document.querySelector('#btnAuto.btn-busy'), { timeout: 15000 }); await settle(page, 400);+ r = await expectReadouts(page, TODAY, 'after Auto-schedule');+ check('Auto-schedule moved the end of the plan', r.last !== LAST, r.last);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);+ r = await expectReadouts(page, TODAY, 'undo of Auto-schedule');+ check('undo of Auto-schedule restores 16w 4d', r.dur === base.dur, r.dur);++ // Table edit of the last row's deadline.+ section = 'live: table edit';+ await view(page, 'table');+ const cellSel = 'tr[data-key] td.c-date[data-field="deadline"]';+ const keyOfFinale = await page.evaluate(() => [...document.querySelectorAll('#tableView tr[data-key]')].find(tr => /Launch finale/.test(tr.textContent)).dataset.key);+ await page.evaluate(k => document.querySelector(`tr[data-key="${k}"]`).scrollIntoView({ block: 'center' }), keyOfFinale);+ // The undo toast from the step before sits over the bottom of the table, and the+ // finale row is near the bottom (the cancelled row sorts after it); a person would+ // wait for it too.+ await page.waitForFunction(() => !document.querySelector('#undoToast.show'), { timeout: 8000 }).catch(() => {});+ await page.click(`tr[data-key="${keyOfFinale}"] td.c-date[data-field="deadline"]`); await settle(page, 100);+ await page.evaluate(() => { const i = document.querySelector('.cell-edit input'); i.value = '2027-01-21'; i.dispatchEvent(new Event('input', { bubbles: true })); });+ await page.keyboard.press('Enter'); await settle(page, 300);+ r = await expectReadouts(page, TODAY, 'after a table deadline edit');+ check('table edit moved the end to 21-Jan-2027 (17w 4d)', r.last === '2027-01-21' && r.dur === '17w 4d', `${r.last} ${r.dur}`);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);+ r = await expectReadouts(page, TODAY, 'undo of the table edit');++ // Delete the last row, then undo.+ section = 'live: delete';+ await page.evaluate(k => document.querySelector(`tr[data-key="${k}"]`).scrollIntoView({ block: 'center' }), keyOfFinale);+ await page.hover(`tr[data-key="${keyOfFinale}"]`);+ await page.$eval(`tr[data-key="${keyOfFinale}"] .row-del`, b => b.click()); await settle(page, 300);+ r = await expectReadouts(page, TODAY, 'after deleting the last row');+ check('deleting the last row shortened the plan', r.last < LAST, r.last);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);+ r = await expectReadouts(page, TODAY, 'undo of the delete');+ check('undo of the delete restores 16w 4d', r.dur === base.dur, r.dur);++ // Paste: copy the last row and paste it; the copy has the same dates, so the span+ // is unchanged, and the readouts must still agree with the plan.+ section = 'live: paste';+ await view(page, 'gantt');+ const nBefore = r.n;+ const lbl = await page.evaluateHandle(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => /Launch finale/.test(x.textContent)); e.scrollIntoView({ block: 'center' }); return e; });+ await settle(page, 100);+ const lb = await lbl.boundingBox();+ // Ctrl+click selects a row without opening its name editor (a plain click edits).+ await page.keyboard.down('Control'); await page.mouse.click(lb.x + 40, lb.y + lb.height / 2); await page.keyboard.up('Control'); await settle(page, 100);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyC'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control');+ r = await expectReadouts(page, TODAY, 'after paste');+ check('paste added a row', r.n === nBefore + 1, `${nBefore} -> ${r.n}`);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);++ // Create a task past the end with the New editor.+ section = 'live: create';+ await view(page, 'gantt');+ await page.click('#btnNew'); await settle(page, 150);+ await page.type('#ncName', 'Post-launch retro');+ await page.evaluate(() => { const s = document.getElementById('ncStart'), e = document.getElementById('ncEnd'); s.value = '2027-01-25'; e.value = '2027-01-29'; s.dispatchEvent(new Event('input', { bubbles: true })); e.dispatchEvent(new Event('input', { bubbles: true })); s.dispatchEvent(new Event('change', { bubbles: true })); e.dispatchEvent(new Event('change', { bubbles: true })); });+ await page.click('#ncCreate'); await settle(page, 800);+ r = await expectReadouts(page, TODAY, 'after creating a task past the end');+ check('the new task moved the end to 29-Jan-2027', r.last === '2027-01-29', r.last);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page, 300);+ r = await expectReadouts(page, TODAY, 'undo of the create');+ check('undo of the create restores 16w 4d', r.dur === base.dur, r.dur);+ await page.close();++ // Plan not started yet, and plan over.+ section = 'dates: clock';+ page = await openPage(browser, S.url, { today: '2026-09-01' });+ r = await readouts(page);+ check('before the plan starts: remaining counts from today (136 days = 19w 3d)', r.left === '19w 3d', r.left);+ check('before the plan starts: state is not-started and the tooltip says so', r.leftState === 'not-started' && /not started/.test(r.leftTip), r.leftTip);+ check('before the plan starts: duration unchanged', r.dur === '16w 4d', r.dur);+ check('before the plan starts: the readout itself says "not started"', await page.$eval('#spanLeftN', e => e.textContent) === 'not started' && await page.$eval('#spanLeftN', e => e.checkVisibility()));+ await page.close();+ page = await openPage(browser, S.url, { today: '2027-02-01' });+ r = await readouts(page);+ check('after the plan ends: remaining reads "ended"', r.left === 'ended' && r.leftState === 'ended', r.left);+ check('after the plan ends: tooltip says how long ago', /18 days ago/.test(r.leftTip), r.leftTip);+ await page.close();+ page = await openPage(browser, S.url, { today: LAST });+ r = await readouts(page);+ check('on the last day: 1 day remaining', r.left === '1 day', r.left);+ // A tab left open overnight: the date rolls over with no edit at all, and coming+ // back to the tab repaints (the minute tick does the same for a tab left in view).+ await page.evaluate(() => { window.__advanceClockDays(1); document.dispatchEvent(new Event('visibilitychange')); });+ await settle(page, 100);+ r = await readouts(page);+ check('the day after, with no edit: remaining turns to "ended"', r.left === 'ended', r.left);+ await page.close();++ // ── 2. Text size persists: live app ──+ section = 'text size (live)';+ page = await openPage(browser, S.url);+ await page.click('#fsUp'); await page.click('#fsUp'); await settle(page);+ check('A+ twice reads 120%', await fsPct(page) === '120%');+ const legendBefore = (await legendGeometry(page)).L.h;+ await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ check('120% survives a refresh', await fsPct(page) === '120%');+ check('and it is applied, not just displayed', await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--fs').trim()) === '1.2');+ await assertSticky(page, 'at 120%');+ await page.click('#fsDown'); await page.click('#fsDown'); await settle(page);+ check('A- twice is back at 100%', await fsPct(page) === '100%');+ await page.click('#fsUp'); await page.click('#fsPct'); await settle(page);+ check('clicking the percentage resets to 100%', await fsPct(page) === '100%');+ await page.close();++ // Cause 2: a second tab opened BEFORE the change used to write its stale size back.+ section = 'text size (two tabs)';+ const ctx = await browser.createBrowserContext();+ const A = await openPage(browser, S.url, { ctx });+ const B = await openPage(browser, S.url, { ctx, clear: false });+ await B.click('#fsUp'); await B.click('#fsUp'); await settle(B);+ await settle(A, 300);+ check('the other open tab follows the new size live', await fsPct(A) === '120%', await fsPct(A));+ await A.bringToFront(); await A.select('#grainSel', 'month'); await settle(A);+ await B.bringToFront(); await B.reload({ waitUntil: 'networkidle0' }); await settle(B);+ check('a save in the other tab no longer resets the size on refresh', await fsPct(B) === '120%', await fsPct(B));+ // Every other pref too (review finding): tab A collapses everything, tab B, opened+ // before that, toggles the theme. B used to write its whole prefs object, empty+ // collapse set included, and A's refresh came back with every lane open.+ await A.bringToFront(); await A.click('#btnCollapseAll'); await settle(A);+ await B.bringToFront(); await B.click('#btnTheme'); await settle(B);+ await A.bringToFront(); await A.reload({ waitUntil: 'networkidle0' }); await settle(A);+ check('another tab saving the theme no longer reopens every lane', await A.$$eval('.lane-toggle.collapsed', e => e.length) === 20, String(await A.$$eval('.lane-toggle.collapsed', e => e.length)));+ check('and the theme that tab chose is kept too', await A.evaluate(() => document.documentElement.getAttribute('data-theme')) === 'light');+ check('and the text size is still 120%', await fsPct(A) === '120%');+ await A.close(); await B.close(); await ctx.close();++ // Cause 1: the exported snapshot never read the size back.+ section = 'text size (snapshot)';+ const html = await (await fetch(S.url + 'export/html')).text();+ check('export/html produced a snapshot', html.includes('const SNAPSHOT = true;'));+ const snapFile = join(TMP, 'snapshot.html'); writeFileSync(snapFile, html);+ const snapUrl = 'file://' + snapFile;+ page = await openPage(browser, snapUrl);+ // A reader who has other prefs stored from some other chart:+ await page.evaluate(() => localStorage.setItem('adom-gantt:prefs', JSON.stringify({ granularity: 'day', theme: 'light', fs: 1.1, collapsed: [0, 1, 2] })));+ await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ check('the snapshot takes the reader\'s text size (110%)', await fsPct(page) === '110%', await fsPct(page));+ check('the snapshot still opens at the author\'s grain', await page.$eval('#grainSel', s => s.value) === 'week');+ check('the snapshot still opens at the author\'s theme', await page.evaluate(() => document.documentElement.getAttribute('data-theme')) === 'dark');+ check('the snapshot ignores a stored collapse set', await page.$$eval('.lane-toggle.collapsed', e => e.length) === 0);+ await page.click('#fsUp'); await settle(page);+ await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ check('A+ in the snapshot survives a refresh (120%)', await fsPct(page) === '120%', await fsPct(page));+ const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('adom-gantt:prefs')));+ 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);+ 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();++ // ── 1. Collapse / expand all ──+ section = 'collapse all';+ page = await openPage(browser, S.url);+ const collapseState = () => page.evaluate(() => ({+ label: document.querySelector('#btnCollapseAll .ca-label').textContent,+ pressed: document.getElementById('btnCollapseAll').getAttribute('aria-pressed'),+ lanes: document.querySelectorAll('.lane-toggle').length,+ shut: document.querySelectorAll('.lane-toggle.collapsed').length,+ visibleRows: [...document.querySelectorAll('.label-row:not(.group)')].filter(e => e.offsetHeight > 0).length,+ }));+ let c = await collapseState();+ check('starts as "Collapse all" with every lane open', c.label === 'Collapse all' && c.shut === 0 && c.visibleRows > 100, JSON.stringify(c));+ await page.click('#btnCollapseAll'); await settle(page);+ c = await collapseState();+ check('one press collapses every lane', c.shut === c.lanes && c.lanes === 20, JSON.stringify(c));+ check('and hides every task and milestone row', c.visibleRows === 0, String(c.visibleRows));+ check('the button now offers "Expand all"', c.label === 'Expand all' && c.pressed === 'true');+ await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ c = await collapseState();+ check('collapse-all persists across a refresh', c.shut === 20 && c.label === 'Expand all', JSON.stringify(c));+ await view(page, 'table');+ const emptyMsg = await page.$eval('#tableView tbody', t => t.innerText);+ check('the table agrees: no rows, and says why', /Every section is collapsed/.test(emptyMsg), emptyMsg.slice(0, 80));+ await page.click('#btnCollapseAll'); await settle(page);+ const tRows = await page.$$eval('#tableView tbody tr[data-key]', e => e.length);+ const nItems = spanOf(await exportPlan(S.url)).n;+ check('Expand all from the table brings every row back', tRows === nItems, `${tRows} of ${nItems}`);+ await view(page, 'gantt');+ c = await collapseState();+ check('and the chart agrees: every lane open', c.shut === 0 && c.label === 'Collapse all', JSON.stringify(c));+ // Mixed state: one lane shut by hand, then the button.+ await page.click('.label-row.group .lane-toggle'); await settle(page);+ c = await collapseState();+ check('per-lane chevron still collapses one lane', c.shut === 1, JSON.stringify(c));+ check('mixed state offers "Collapse all"', c.label === 'Collapse all');+ await page.click('#btnCollapseAll'); await settle(page);+ c = await collapseState();+ check('from mixed, one press collapses the rest', c.shut === 20, JSON.stringify(c));+ // Open every lane by hand except... all of them: the button follows the chevrons.+ await page.evaluate(() => document.querySelectorAll('.lane-toggle.collapsed').forEach(b => b.click())); await settle(page);+ c = await collapseState();+ check('opening lanes by hand turns the button back to "Collapse all"', c.shut === 0 && c.label === 'Collapse all', JSON.stringify(c));+ await page.close();++ // ── 6. Legend wraps ──+ section = 'legend';+ page = await openPage(browser, S.url);+ let lg = await legendGeometry(page);+ check('20 phase chips rendered', lg.chips.length === 20, String(lg.chips.length));+ check('the legend wraps onto more than one line', lg.lines >= 2, `${lg.lines} line(s), ${lg.L.h}px`);+ check('every chip is inside the legend and the window', lg.chips.every(c => c.right <= lg.L.right + 0.5 && c.bottom <= lg.L.bottom + 0.5 && c.right <= lg.vw), JSON.stringify(lg.chips.filter(c => c.right > lg.L.right || c.bottom > lg.L.bottom).slice(0, 2)));+ check('every chip is clickable (nothing covers it)', lg.chips.every(c => c.clickable));+ check('the chart starts right below the legend', Math.abs(lg.chartTop - lg.L.bottom) <= 1.5, `${lg.chartTop} vs ${lg.L.bottom}`);+ // The last chip is the one a one-line legend clipped; filter with it.+ const last = (await page.$$('#legend .phase-chip')).at(-1);+ await last.click(); await settle(page);+ const dimmed = await page.$$eval('.label-row.dim:not(.group)', e => e.length);+ check('clicking the 20th chip filters its lane (its 8 rows dim, nothing else)', dimmed === 8 && await page.evaluate(() => document.querySelectorAll('#legend .phase-chip')[19].classList.contains('off')), `${dimmed} dimmed`);+ await last.click(); await settle(page);+ await assertSticky(page, '1440 wrapped legend');+ // Taller legend at 150% text, then a narrower window, then back.+ for (let i = 0; i < 4; i++) await page.click('#fsUp');+ await settle(page, 400);+ lg = await legendGeometry(page);+ check('at 150% the legend grows and still shows every chip', lg.lines >= 3 && lg.chips.every(c => c.clickable && c.bottom <= lg.L.bottom + 0.5), `${lg.lines} lines`);+ await assertSticky(page, '150% text');+ await page.setViewport({ width: 1100, height: 900 }); await settle(page, 500);+ lg = await legendGeometry(page);+ check('resized to 1100: chart still meets the legend', Math.abs(lg.chartTop - lg.L.bottom) <= 1.5);+ await assertSticky(page, 'resized to 1100');+ await page.setViewport({ width: 1440, height: 900 }); await settle(page, 500);+ await assertSticky(page, 'resized back to 1440');+ await page.click('#fsPct'); await settle(page, 300);+ // Table: its sticky header must stay at the top of its own scroller under a wrapped legend.+ await view(page, 'table');+ const th = await page.evaluate(async () => {+ const head = document.querySelector('#tableView thead th');+ let sc = head.parentElement; while (sc && sc !== document.body && !(sc.scrollHeight > sc.clientHeight + 4 && /(auto|scroll)/.test(getComputedStyle(sc).overflowY))) sc = sc.parentElement;+ sc.scrollTop = 600; await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));+ const o = { scrolled: sc.scrollTop, thTop: head.getBoundingClientRect().top, scTop: sc.getBoundingClientRect().top, legendBottom: document.getElementById('legend').getBoundingClientRect().bottom };+ sc.scrollTop = 0; return o;+ });+ check('table header sticks to the top of the table under the legend', th.scrolled > 100 && Math.abs(th.thTop - th.scTop) <= 2 && th.thTop >= th.legendBottom - 0.5, JSON.stringify(th));+ await shot(page, 'desktop-1440-table');+ await page.close();++ // ── Print: the plan is named on paper even though the header no longer paints it ──+ section = 'print';+ page = await openPage(browser, S.url);+ await page.emulateMediaType('print'); await settle(page, 200);+ const pr = await page.evaluate(() => ({+ title: (() => { const e = document.getElementById('printTitle'); return e && e.checkVisibility() ? e.innerText : ''; })(),+ ca: document.getElementById('btnCollapseAll').checkVisibility(),+ }));+ check('a printed chart carries the title and subtitle', pr.title.includes(plan.project.title) && pr.title.includes(plan.project.subtitle), pr.title);+ check('Collapse all does not print', !pr.ca);+ await page.emulateMediaType('screen'); await settle(page, 150);+ check('the print title is not on screen', !(await page.$eval('#printTitle', e => e.checkVisibility())));+ await page.close();++ // ── All cancelled, and a plan written in months ──+ section = 'edge plans';+ const AC = await startServer({ project: { title: 'Called Off', appName: 'called-off' }, phases: [{ name: 'Only', start: '2026-09', end: '2026-12' }],+ items: [{ type: 'task', id: 'a', name: 'Was the plan', start: '2026-10-05', end: '2026-10-09', phase: 0, status: 'cancelled', uid: '00001' }], }, 'allcancelled');+ page = await openPage(browser, AC.url);+ r = await readouts(page);+ check('every row cancelled: both readouts say none, and why', r.dur === 'none' && r.left === 'none' && /Every dated row in the plan is cancelled/.test(r.durTip), JSON.stringify(r));+ await page.close();+ const MO = await startServer({ project: { title: 'Month Plan', appName: 'month-plan' }, phases: [{ name: 'Only', start: '2026-10', end: '2026-11' }],+ items: [{ type: 'task', id: 'a', name: 'Written in months', start: '2026-10', end: '2026-11', phase: 0, uid: '00001' }], }, 'months');+ page = await openPage(browser, MO.url);+ r = await readouts(page);+ check('year-month data: the start button still shows a day (01-Oct-2026)', r.start === '01-Oct-2026', r.start);+ check('year-month data: duration runs 01-Oct to 30-Nov (61 days = 8w 5d)', r.dur === '8w 5d' && /01-Oct-2026/.test(r.durTip) && /30-Nov-2026/.test(r.durTip), `${r.dur} ${r.durTip}`);+ await page.close();++ // ── Empty plan, and a plan with no appName ──+ section = 'empty plan';+ const E = await startServer({ project: { title: 'Nothing Scheduled Yet', subtitle: 'An empty plan' }, phases: [{ name: 'Only lane', start: '2026-09', end: '2026-10' }], items: [] }, 'empty');+ page = await openPage(browser, E.url);+ r = await readouts(page);+ check('empty plan: both readouts say none', r.dur === 'none' && r.left === 'none', JSON.stringify(r));+ check('empty plan: start button says there are no dates', r.start === 'no dates', r.start);+ check('no appName: the brand shows the project title', await page.$eval('#hdr .app-name', e => e.textContent) === 'Nothing Scheduled Yet');+ check('empty plan: Collapse all is disabled (nothing to hide)', await page.$eval('#btnCollapseAll', b => b.disabled));+ await page.close();++ // ── Viewport matrix ──+ for (const vp of [{ w: 393, h: 852, mobile: true }, { w: 1440, h: 900 }, { w: 1920, h: 1080 }]) {+ section = `viewport ${vp.w}`;+ page = await openPage(browser, S.url, vp);+ await assertNoSidewaysScroll(page, 'chart');+ lg = await legendGeometry(page);+ check('all 20 phase chips visible and clickable', lg.chips.length === 20 && lg.chips.every(c => c.clickable && c.right <= vp.w));+ r = await readouts(page);+ check('duration readouts present', r.dur === '16w 4d' && r.left === '15w 3d', JSON.stringify(r));+ const readoutVisible = await page.evaluate(() => { const e = document.getElementById('spanLeftV'); const x = e.getBoundingClientRect(); const s = document.querySelector('#hdr .subject').getBoundingClientRect();+ return e.checkVisibility() && x.width > 0 && x.right <= innerWidth && x.right <= s.right + 0.5; });+ check('Time Remaining is on screen and not clipped', readoutVisible);+ await page.click('#btnCollapseAll'); await settle(page);+ check('Collapse all works here', await page.evaluate(() => document.querySelectorAll('.lane-toggle.collapsed').length) === 20);+ await page.click('#btnCollapseAll'); await settle(page);+ await shot(page, `viewport-${vp.w}`);+ await view(page, 'table');+ await assertNoSidewaysScroll(page, 'table');+ await shot(page, `viewport-${vp.w}-table`);+ await page.close();+ }++ // ── Header width sweep ──+ // The two readouts Drew asked for must be whole at EVERY width, with the plan's+ // appName and with the title fallback (no appName, a longer brand), at 100% and+ // 150% text, and no header control may sit outside the window. The widths are a+ // 40px sweep plus the ones real screens and the adversary review used; set+ // SWEEP_STEP=20 for a denser pass.+ section = 'header sweep';+ const NA = await startServer(seedPlan({ appName: null, title: 'instaPCB Launch: Jan 11, 2027' }), 'no-appname');+ await headerSweep(browser, [['appName', S.url], ['title fallback', NA.url]]);++ // ── Coverage gate ──+ section = 'coverage';+ await coverageGate(browser, S.url);++ // ── Leak watch (cheap): listeners and nodes across repeated toggles ──+ section = 'leak';+ page = await openPage(browser, S.url);+ // Collect first, both times: Nodes and listeners count detached nodes until a GC+ // runs, and when that happens is up to V8, so an uncollected run reads as a leak.+ const gcCdp = await page.createCDPSession();+ const metricsAfterGc = async () => { await gcCdp.send('HeapProfiler.collectGarbage'); await sleep(100); await gcCdp.send('HeapProfiler.collectGarbage'); return page.metrics(); };+ // one warm-up round, so lazily built things (the table body, caches) are not counted as growth+ await page.click('#btnCollapseAll'); await page.click('#btnCollapseAll'); await page.click('#fsUp'); await page.click('#fsDown'); await settle(page, 400);+ const m0 = await metricsAfterGc();+ for (let i = 0; i < 20; i++) { await page.click('#btnCollapseAll'); await page.click('#fsUp'); await page.click('#fsDown'); }+ for (const g of ['day', 'month', 'week', 'quarter', 'week']) await setGrain(page, g);+ await setGrain(page, 'week'); await settle(page, 600);+ const m1 = await metricsAfterGc();+ check('no listener growth over 20 collapse-all + 40 text-size + 5 grain changes', m1.JSEventListeners - m0.JSEventListeners <= 5, `${m0.JSEventListeners} -> ${m1.JSEventListeners}`);+ check('DOM nodes bounded (back at the same grain, same count within 500)', Math.abs(m1.Nodes - m0.Nodes) < 500, `${m0.Nodes} -> ${m1.Nodes}`);+ await page.close();++ // ── Optional: the real plan ──+ if (REAL_PLAN) {+ section = 'real plan';+ const R = await startServer(REAL_PLAN, 'real');+ SRV = R.url;+ page = await openPage(browser, R.url);+ const real = JSON.parse(readFileSync(REAL_PLAN, 'utf8'));+ lg = await legendGeometry(page);+ check(`all ${real.phases.length} chips visible and clickable`, lg.chips.length === real.phases.length && lg.chips.every(c => c.clickable));+ await expectReadouts(page, TODAY, 'real plan');+ const hd = await page.evaluate(() => document.getElementById('hdr').innerText);+ check('title not repeated in the header', !hd.includes(real.project.title));+ await assertSticky(page, 'real plan');+ await assertNoSidewaysScroll(page, 'real plan');+ await shot(page, 'real-plan-1440');+ await page.setViewport({ width: 393, height: 852, isMobile: true, hasTouch: true }); await settle(page, 500);+ await assertNoSidewaysScroll(page, 'real plan 393');+ await shot(page, 'real-plan-393');+ await page.close();+ // The same width sweep on the real plan, with its appName and without it.+ const noApp = JSON.parse(readFileSync(REAL_PLAN, 'utf8')); delete noApp.project.appName;+ const RN = await startServer(noApp, 'real-noapp');+ await headerSweep(browser, [['real plan, appName', R.url], ['real plan, title fallback', RN.url]]);+ }++ section = '';+ check('NO uncaught page errors or console errors on any page', pageErrors.length === 0, pageErrors.slice(0, 5).join(' | '));+ } finally {+ await browser.close().catch(() => {});+ }+}++// 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) {+ return page.evaluate(() => {+ const clipRect = el => {+ let r = el.getBoundingClientRect(); let L = r.left, R = r.right;+ for (let a = el.parentElement; a && a !== document.documentElement; a = a.parentElement) {+ const cs = getComputedStyle(a);+ if (cs.overflowX !== 'visible' || cs.overflow !== 'visible') { const ar = a.getBoundingClientRect(); L = Math.max(L, ar.left); R = Math.min(R, ar.right); }+ }+ return { L, R, full: r };+ };+ const bad = [];+ for (const id of ['spanDur', 'spanLeft']) {+ const e = document.getElementById(id);+ if (!e || !e.checkVisibility()) { bad.push(id + ' hidden'); continue; }+ const c = clipRect(e);+ if (c.full.width < 20 || c.L > c.full.left + 0.5 || c.R < c.full.right - 0.5 || c.full.right > innerWidth + 0.5) bad.push(`${id} clipped ${Math.round(c.full.left)}..${Math.round(c.full.right)} visible ${Math.round(c.L)}..${Math.round(c.R)}`);+ for (const k of e.children) if (k.scrollWidth > k.clientWidth + 1) bad.push(id + ' text cut');+ }+ const off = [...document.querySelectorAll('#hdr button, #hdr select')].filter(e => e.checkVisibility())+ .map(e => ({ e, r: e.getBoundingClientRect() })).filter(x => x.r.right > innerWidth + 0.5 || x.r.left < -0.5)+ .map(x => (x.e.id || x.e.className) + '@' + Math.round(x.r.right));+ return { bad, off, text: document.getElementById('spanDur')?.innerText.replace(/\s+/g, ' ') + ' / ' + document.getElementById('spanLeft')?.innerText.replace(/\s+/g, ' ') };+ });+}+async function headerSweep(browser, targets) {+ const step = +(process.env.SWEEP_STEP || 40);+ 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);+ for (const [label, url] of targets) {+ for (const fs of [1, 1.5]) {+ const page = await openPage(browser, url, { w: 1440, h: 900 });+ if (fs !== 1) { for (let i = 0; i < 4; i++) await page.click('#fsUp'); await settle(page, 200); }+ const fails = [];+ for (const w of W) {+ await page.setViewport({ width: w, height: 900 });+ await settle(page, 120);+ const p = await headerProbe(page);+ if (p.bad.length || p.off.length) fails.push(`${w}px: ${[...p.bad, ...p.off.map(o => 'off-screen ' + o)].join(', ')}`);+ }+ check(`${label} at ${fs * 100}%: readouts whole and every header control on screen at ${W.length} widths 360..2000`, fails.length === 0, `${fails.length} widths fail; ${fails.slice(0, 5).join(' | ')}`);+ await page.close();+ }+ }+}++// ── Control-coverage manifest ──+// Every control the app renders in the states below. safe: the gate clicks it and+// asserts nothing throws. terminal: it changes the plan, leaves the page or opens a+// file chooser, so it is presence-checked here and its effect is proven by the named flow.+const MANIFEST = [+ { sel: '.vt-btn', label: 'lens switch', safe: true },+ { sel: '#startBtn', label: 'start date', safe: true },+ { sel: '#grainSel', label: 'grain picker', safe: false, terminal: true, by: 'dates: every grain' },+ { sel: '#btnFit', label: 'Fit', safe: true },+ { sel: '#btnDeps', label: 'dependency mode', safe: true, twice: true },+ { sel: '#btnAuto', label: 'Auto-schedule', terminal: true, by: 'live: auto-schedule' },+ { 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 },+ { sel: '#btnNew', label: 'New', safe: true },+ { sel: '#fsDown', label: 'A-', safe: true },+ { sel: '#fsPct', label: 'text size reset', safe: true },+ { sel: '#fsUp', label: 'A+', safe: true },+ { sel: '#btnCollapseAll', label: 'Collapse / expand all', safe: true, twice: true },+ { sel: '.lane-toggle', label: 'lane chevron', safe: true, twice: true },+ { sel: '.phase-chip', label: 'phase legend chip', safe: true, twice: true },+ { sel: '.legend-item', label: 'milestone / deps / today legend toggles', safe: true, twice: true },+ { sel: '.dp-nav', label: 'date picker year step', safe: true, required: true },+ { sel: '.dp-cell', label: 'date picker month', terminal: true, by: 'start picker' },+ { sel: '.dp-link', label: 'date picker Revert / Today', terminal: true, by: 'start picker (Revert covered by undo path; Today is 0.56.0 behaviour)' },+ { sel: '.dd-item', label: 'menu items', terminal: true, by: 'text size (snapshot) uses export/html; import items open a file chooser' },+ { sel: '.sec-sw', label: 'section colour', terminal: true, by: '0.56.0 behaviour, unchanged' },+ { sel: '.sec-btn', label: 'section row actions', terminal: true, by: '0.56.0 behaviour, unchanged' },+ { sel: '#secNew', label: 'new section name', safe: false, terminal: true, by: '0.56.0 behaviour, unchanged' },+ { sel: '#secAdd', label: 'add section', terminal: true, by: '0.56.0 behaviour, unchanged' },+ { sel: '#secDone', label: 'close Sections', terminal: true, by: 'coverage state reset' },+ { sel: '#ncType', label: 'new: type', terminal: true, by: 'live: create' },+ { sel: '#ncPhase', label: 'new: section', terminal: true, by: 'live: create' },+ { sel: '#ncName', label: 'new: name', terminal: true, by: 'live: create' },+ { sel: '#ncOwner', label: 'new: owner', terminal: true, by: 'live: create' },+ { sel: '#ncStatus', label: 'new: status', terminal: true, by: 'live: create' },+ { sel: '#ncStart', label: 'new: start', terminal: true, by: 'live: create' },+ { sel: '#ncEnd', label: 'new: deadline', terminal: true, by: 'live: create' },+ { sel: '#ncDesc', label: 'new: description', terminal: true, by: 'live: create' },+ { sel: '#ncProv', label: 'new: provenance', terminal: true, by: 'live: create' },+ { sel: '#ncCreate', label: 'new: create', terminal: true, by: 'live: create' },+ { sel: '#ncCancel', label: 'new: cancel', terminal: true, by: 'coverage state reset' },+ { sel: '#tblOwner', label: 'table owner filter', terminal: true, by: '0.56.0 behaviour, unchanged' },+ { sel: '#tblStatus', label: 'table status filter', terminal: true, by: '0.56.0 behaviour, unchanged' },+ { sel: '#tblSearch', label: 'table search', terminal: true, by: '0.56.0 behaviour, unchanged' },+ { sel: '#toPlanOrder', label: 'table: back to plan order', safe: true, required: false },+];+function ctrlMatches(c, sel) {+ if (sel.startsWith('#')) return c.id === sel.slice(1);+ if (sel.startsWith('.')) return c.cls.split(/\s+/).includes(sel.slice(1));+ return c.tag === sel;+}+// Most specific entry wins: an id beats a class, so #btnCollapseAll is not read as a+// plain .legend-item and a phase chip is not read as the generic toggle.+function manifestFor(c) {+ return MANIFEST.find(d => d.sel.startsWith('#') && ctrlMatches(c, d.sel))+ || MANIFEST.find(d => d.sel === '.phase-chip' && ctrlMatches(c, d.sel))+ || MANIFEST.find(d => ctrlMatches(c, d.sel));+}+async function liveControls(page) {+ return page.evaluate(() => {+ const vis = e => {+ if (e.checkVisibility && !e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true, opacityProperty: true, visibilityProperty: true })) return false;+ const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0;+ };+ // .legend-item and .dd-item are divs with listeners, not buttons, and are real+ // controls all the same, so they are enumerated too.+ return [...document.querySelectorAll('button,[onclick],select,textarea,input:not([type=hidden]),[role="button"],.legend-item,.dd-item')]+ .filter(vis).filter(e => !e.classList.contains('static'))+ .map((e, i) => { e.dataset.cov = String(i); return { i, tag: e.tagName.toLowerCase(), id: e.id || '', cls: typeof e.className === 'string' ? e.className : '', onclick: e.getAttribute('onclick') || '', text: (e.textContent || '').trim().slice(0, 30) }; })+ // A signature plus its ordinal among equal signatures is how a control is found+ // again after the state is reopened; a bare index would drift onto a neighbour.+ .map((c, _, all) => ({ ...c, sig: `${c.tag}|${c.id}|${c.cls.replace(/\b(on|off|collapsed|is-all|sel|shifted)\b/g, '').trim()}|${c.text}` }))+ .map((c, i, all) => ({ ...c, nth: all.slice(0, i).filter(x => x.sig === c.sig).length }));+ });+}+async function coverageGate(browser, url) {+ const page = await openPage(browser, url);+ const reset = async () => {+ await page.keyboard.press('Escape'); await page.keyboard.press('Escape');+ // an outside click is how the menus and the date picker close for a person too+ await page.mouse.click(60, 22); await settle(page, 120);+ };+ const STATES = [+ ['chart', async () => { await view(page, 'gantt'); }],+ ['date picker', async () => { await view(page, 'gantt'); await page.click('#startBtn'); }],+ ['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'); }],+ ['new item', async () => { await page.click('#btnNew'); }],+ ['table', async () => { await view(page, 'table'); }],+ ['table sorted', async () => { await view(page, 'table'); await page.evaluate(() => document.querySelector('#tableView thead th[data-sort]')?.click()); }],+ ['analytics', async () => { await view(page, 'analytics'); }],+ ];+ const seen = new Set(), orphans = [];+ for (const [name, open] of STATES) {+ await open(); await settle(page, 200);+ for (const c of await liveControls(page)) {+ const hit = manifestFor(c);+ if (hit) seen.add(hit.sel);+ else orphans.push(`[${name}] <${c.tag} id="${c.id}" class="${c.cls.slice(0, 40)}"> "${c.text}"`);+ }+ await reset();+ }+ await view(page, 'gantt');+ check('every rendered control is declared (no orphans)', orphans.length === 0, [...new Set(orphans)].slice(0, 8).join(' | '));+ const missing = MANIFEST.filter(d => d.required !== false && !seen.has(d.sel)).map(d => d.sel);+ check('every required control rendered in some state (no fiction)', missing.length === 0, missing.join(', '));+ check('every terminal control names the flow that proves it', MANIFEST.filter(d => d.terminal).every(d => d.by));++ // Click every safe control, in the state where it lives, and fail on any page error.+ const before = pageErrors.length;+ const walkBefore = spanOf(await savedPlan(url, { minWait: 200 }));+ let clicked = 0, covered = 0, skipped = [];+ for (const [name, open] of STATES) {+ await open(); await settle(page, 150);+ const ctrls = await liveControls(page);+ for (const c of ctrls) {+ const d = manifestFor(c);+ if (!d || !d.safe) continue;+ // Re-find it right before each click, by signature: the chart repaints, detaches+ // nodes and renumbers, and a stale handle clicks whatever took its place.+ const fresh = (await liveControls(page)).find(x => x.sig === c.sig && x.nth === c.nth);+ const h = fresh && await page.$(`[data-cov="${fresh.i}"]`);+ const box = h && await h.boundingBox();+ if (!box) { skipped.push(`${name}:${d.sel}`); continue; }+ // A mouse click lands on whatever is on top at that point. In an overlay state+ // (the date picker hangs over the legend) a chip under the overlay is not+ // clickable by a person either, and "clicking" it would really click the+ // overlay's month cell and move the plan. It was clicked in the chart state.+ const onTop = await h.evaluate(e => { const r = e.getBoundingClientRect(); const t = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); return !!t && (t === e || e.contains(t)); });+ if (!onTop) { covered++; continue; }+ try {+ if (process.env.COV_DEBUG) console.log(' click', name, c.sig, c.nth);+ await h.click(); clicked++;+ if (d.twice) { await settle(page, 60); const again = (await liveControls(page)).find(x => x.sig === c.sig && x.nth === c.nth);+ const h2 = again && await page.$(`[data-cov="${again.i}"]`); if (h2 && await h2.boundingBox()) await h2.click(); }+ } catch (e) { skipped.push(`${name}:${d.sel} (${e.message.slice(0, 40)})`); }+ await settle(page, 60);+ // A click that opened something (a menu, the modal, a lens) is closed before the+ // next, so each control is clicked in the state the walk opened, not a stray one.+ await reset(); await open(); await settle(page, 60);+ }+ await reset();+ }+ // The walk clicks only safe controls, so the plan must come out of it untouched.+ const after = spanOf(await exportPlan(url));+ check('the safe-control walk did not change the plan', after.first === walkBefore.first && after.last === walkBefore.last && after.n === walkBefore.n, JSON.stringify({ walkBefore, after }));+ console.log(` (${covered} controls sat under an open overlay in some state and were clicked in another)`);+ check(`clicked every safe control (${clicked} clicks) with no page error`, pageErrors.length === before && clicked > 40, pageErrors.slice(before, before + 3).join(' | ') + (skipped.length ? ' skipped: ' + skipped.slice(0, 5).join(', ') : ''));+ await page.close();+}++let exitCode = 1;+try {+ await main();+ exitCode = fail ? 1 : 0;+} catch (e) {+ console.error('\nSUITE CRASHED:', e && e.stack || e);+ fail++; failures.push('suite crashed: ' + (e && e.message));+} finally {+ for (const s of servers) { try { s.proc.kill('SIGTERM'); } catch {} }+ await sleep(200);+ for (const s of servers) { try { s.proc.kill('SIGKILL'); } catch {} }+ if (KEEP) console.log(`\nkept: temp state ${TMP}, screenshots ${OUT}`);+ else rmSync(TMP, { recursive: true, force: true });+ console.log(`\n${pass} passed, ${fail} failed`);+ if (failures.length) console.log('Failures:\n - ' + failures.join('\n - '));+ process.exit(fail ? 1 : exitCode);+}