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.59.0
10 files changed
+1108−61
README.md+15−3@@ -12,7 +12,7 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc **Analytics.** A third lens, next to Gantt and Table. Total effort for the plan in hours (working days times eight, a forty-hour week), the working hours LEFT between today and the project end, the AVERAGE hours per assigned person, then one row per person carrying their hours, share and item counts alongside their five status counts, the unassigned bucket included. Status counts overall, per-section totals with the range each section actually covers, milestones with days from today, dependency stats with a cycle warning, and everything overdue. Read-only, whole-plan, and re-derived from the same rows every time you switch to it. -**Setup.** A fourth lens, since 0.58.0, for the plan's structure rather than its work. It is where the sections are managed: add one, rename it and set its aside, recolour it, move it up or down, or remove it, each row showing how many rows the section holds and the dates they span. It replaces the Sections button that used to sit in the header and open a modal. Every change is still one Ctrl+Z and one changelog line.+**Setup.** A fourth lens, since 0.58.0, for the plan's structure rather than its work. It is where the sections are managed: add one, rename it and set its aside, recolour it, move it up or down, or remove it, each row showing how many rows the section holds and the dates they span. Since 0.59.0 it is also where sections are tagged, one by one or from their name prefixes in one click, and where a tag is renamed or removed everywhere. It replaces the Sections button that used to sit in the header and open a modal. Every change is still one Ctrl+Z and one changelog line. **Four lenses over one plan.** A **Gantt / Table / Analytics / Setup** toggle in the header. The chart answers "when". The table answers "whose, and by when": every task and every milestone as a row with Owner, Start, Deadline, Status and Notes, sortable on any column, filtered to one person with the owner dropdown, and editable in place. An edit made in the table is already true on the chart when you toggle back, because there is only one set of items underneath. The choice of lens persists, and `project.view.mode` can pin it for a plan that is an assignment list more than a timeline. @@ -64,7 +64,7 @@ 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. The legend wraps onto as many lines as the plan needs, so a plan with twenty sections shows all twenty chips.+**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. When the sections carry tags (0.59.0), the legend leads with a chip per tag and the twenty section chips fold behind one "20 sections" chip; pick a tag and only the sections carrying it are shown, in the chart and the table, with their own chips back in the legend. Pick several to see any of them; Clear tags shows everything. It is your view only, and it survives a refresh. **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. @@ -155,7 +155,18 @@ short list of short strings. The server trims them, drops duplicates that differ in case (the first spelling wins), caps each at 32 characters and the list at 12, and writes no `tags` key at all when there are none, so a plan without tags round-trips unchanged. A tag change is saved in the sidecar and logged as `tags` (a row) or-`phase.tags` (a section).+`phase.tags` (a section). Section tags travel in the JSON export and the HTML snapshot;+a CSV is rows, so its `tags` column carries row tags only.++Since 0.59.0 section tags are edited on the **Setup** tab: a tags line under each section+(type a tag, Enter or a comma to add it, the x on a chip to remove it, suggestions from+the tags already in the plan) and a **Tags** card that lists every tag with how many+sections carry it and renames or removes one everywhere. If sections are named with a+prefix, like "Tech · Fab", one button tags each with its prefix; it adds tags and never+renames. In the legend, a chip per tag leads the line and the section chips fold behind+it: choose one or more tags to see only the sections carrying any of them, in the chart+and the table. Every tag edit is one Ctrl+Z; the filter is your view only and survives a+refresh. Omit `color` on a phase and it follows the Adom brand ramp, which adapts between dark and print. See [SKILL.md](SKILL.md) for the full field reference and the ramp values. @@ -365,6 +376,7 @@ table, the item editor or the batch editor are taken exactly as typed. | 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 |+| Click a # tag chip in the legend | Show only the sections carrying that tag (or any of the chosen tags), chart and table. Clear tags undoes it. Your view only | | 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) |
SKILL.md+31−1@@ -236,6 +236,7 @@ rather than a category. - **Auto-schedule** button, with the plan-level controls next to Fit, in the chart and the table lenses. One press lays the WHOLE plan out under two rules at once: no task with dependencies starts before its latest predecessor's end date, and no task starts or ends on a Saturday, a Sunday or a US federal holiday. The handoff date is a MINIMUM and not an answer: it is BACK TO BACK for a task that was overlapping and is pushed onto it, and back to back is a statement about the CHART, since a bar runs from its start to its end and the end is the boundary it stops at, so a successor starting on that date draws flush against it with no gap and no overlap. **PUSH ONLY, since 0.53.0.** A task that already starts on or after its handoff is LEFT WHERE IT IS, however wide the gap, because a gap in a real plan is usually somebody's lead time: an order placed on the Monday and the parts that arrive on the Thursday keep their three days. Through 0.52.0 the pass went both ways and closed every one of those, and there was no way to keep one. Nothing is ever pulled EARLIER now, so the plan cannot drift backwards over a press, and the pass reaches a fixed point in ONE press: the second moves nothing. Pushes still cascade, each successor measured against where its predecessor LANDED and not against the date it shipped with. **CANCELLED WORK IS NOT IN THE PASS AT ALL,** also since 0.53.0: it is never moved, so its dates freeze exactly as set even if they violate something, and it constrains nobody, so a task whose only predecessors are cancelled behaves like an anchor and one with a mix is pushed by its LIVE predecessors alone. The toast counts those rows as their own third number ("5 tasks moved, 15 unchanged, 1 cancelled excluded") rather than folding them into the count of work that was already aligned. A task with no dependencies is an ANCHOR and keeps its start (shifted LATER off a weekend or a holiday if it was on one), because that is the one thing in a plan this cannot derive. Duration is preserved in WORKING days, not calendar days, so a five-day task laid across a weekend, or across Labor Day, still takes five working days: a predecessor ending on the Friday hands off on the Friday and the weekend falls inside the successor's own span, never between the two. Milestones take the same rule with no duration: off a weekend or a holiday to the next working day, and on the day their last dependency ends. The graph crosses sections freely and so does the schedule; a section's displayed range just follows its rows. A dependency CYCLE is left exactly as it is, along with anything downstream of it, and the toast names the rows: there is no honest answer for a row that waits on itself. The whole pass is ONE undo entry and ONE changelog event (`action: "auto-schedule"`, with a count and the names of what moved), and a press that moves nothing writes nothing. Deterministic. Disabled, with a tooltip that says why, when the plan has no dependencies - **Critical** button, next to it: the LONGEST run of work in the plan. The dependency graph is a DAG, a task WEIGHS its duration in working days (weekends and observed US federal holidays out, the same arithmetic everything else in the app counts with) and a milestone weighs nothing, and the critical path is the maximum-weight path through it, found by the classic longest-path DP. It reads the dates the plan has right now, so it is independent of Auto-schedule and never moves anything. **This is not zero-float CPM** and it deliberately stopped being it in 0.49.0: CPM anchors its backward pass on the plan's latest finish, so it always answers "which chain ends last", and on a real plan that picks a short simple chain running into December over a ten-row interconnected build finishing in November. An isolated row is a path of one and can win if it is genuinely the longest thing there is. **One button, an on/off TOGGLE**, with one constant label and the state carried by its pressed treatment: off, or one longest path inside EVERY section at once, all lit together. 0.49.0 offered a plan-wide reading as a third state of the same button and 0.50.0 dropped it, because on a real plan it lights one section's chain and leaves every other section dark, which is a narrower reading than the per-section answer that contains it. With no label to swap, the button sizes off its own content like every other header action rather than being pinned to the widest of three words. Each lane is answered on its OWN subgraph: the nodes are that section's rows and the edges are the dependencies whose two ends are both in it, so a dependency that crosses sections is not an edge there and can never light up in that mode, and a section with no internal dependencies lights its longest single ROW, which is the same rule rather than an exception to it. **CANCELLED WORK IS NOT A NODE, since 0.53.0:** it can never glow, it carries no weight, and a chain that ran through it is BROKEN at that row, since the node is absent and its predecessors and successors are no longer joined by way of it. A section with a second route round the cancelled row is REROUTED onto it; a section whose only route ran through it falls back to the longest single row left, which may be neither of the two stumps' first halves. The longest-single-row fallback ignores cancelled rows for the same reason: they are not there to be considered. Nothing is deleted from the data, so un-cancelling puts the node, its links and every path through it straight back. Ties are broken deterministically, in this order: more working days, then a greater calendar span (first start to last end), then more rows, then whichever begins earlier in plan order, so exactly one path is ever lit per answer. A GAP adds no weight, because a path's length is the sum of the NODES along it and not the calendar it is spread over, so the gaps 0.53.0's push-only Auto-schedule now preserves cannot promote or demote a chain; the calendar span is reachable only by the second tie-break, where it separates two chains already tied on working days and says the one occupying more of the calendar is the more critical, which is the reading it has always had. The bars, the arrows along the path and any milestone on it glow white and breathe on a 2.6s cycle, in a saturated indigo in light mode and on paper, and the table lens marks the same rows on their left edge. Purely additive: selection outlines, dependency-mode amber and armed red all still read on a glowing row, and hit-testing is untouched. It recomputes live as dates and dependencies change, and the state is a saved preference beside the theme and the milestone style; a browser that stored the old plan-wide mode comes back lit, since that reader had the glow on. A collapsed section hides its own glow with its rows and the rest of the plan keeps glowing. Disabled, with the same message it has always used, when the plan has no dependencies at all: a path is a run of work THROUGH dependencies, and a plan where nothing waits on anything has no runs to be longest - **Click legend** to filter phases, milestones, dependency arrows, or the today line. The legend WRAPS onto as many lines as it needs (0.57.0), so every phase chip on a 20-section plan is visible and clickable; until 0.56.0 it was one line and clipped everything past the pane edge. Below 1150px the chips drop their words and keep the colour dot, as before+- **Tag chips in the legend** (0.59.0). When the plan's sections carry tags, the legend leads with a chip per tag (`# Tech 7`: the tag and how many sections carry it) and the section chips stand BEHIND them: with no tag chosen they fold away behind one "20 sections" chip (click it to open them all; that is remembered as `prefs.legendSecs`), and with a tag chosen the chips of exactly the sections on screen come back after the tags. A section chip whose name prefix is a chosen tag drops the repeat ("Laser workcells" behind "# Tech"). Choosing tags shows only the sections carrying ANY of them, headers and rows, in the chart and the table alike; the readouts, Analytics and everything else stay whole-plan. It composes with the section chips (which still switch a shown section off) and with Collapse all (which then counts only the shown sections). **Clear tags** shows everything again. The filter is view state, per person, in `prefs.tagFilter`, so it survives a refresh; a tag the plan no longer has drops out of it. A plan with no tags has no tag chips and no fold, and its legend is what it always was - **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@@ -428,6 +429,35 @@ The legend and the chart-only controls are hidden here, as on Analytics. The len saved preference like the others, so a refresh on Setup comes back to Setup, and `project.view.mode: "setup"` opens a plan on it. It works in an exported HTML snapshot too. +**Tags on sections (0.59.0).** Under each section's name is its tags line: the tags as+small chips, each with an x to remove it, and a box to add one (type, then Enter or a+comma, or click away; a pasted "a, b" adds both; Escape discards what was typed; the+suggestions are the tags already in the plan). A+tag the section already has, in any case, is not added twice, and a section holds at most+12. Above the Sections card (it is short and the section list is long, so it is not lost+under twenty rows), a **Tags** card lists every tag in the plan in the order it+first appears, with how many sections carry it, and can **Rename** a tag everywhere (in+place in each list; a rename onto a tag a section already has merges into it, and a reader's+tag filter follows the rename) or **Remove** it everywhere. When some sections are named+with a prefix, like "Launch · Program and gates", the card offers one button, **Tag them:+Launch, Tech, ...**, which adds each prefix (the text before the first " · ") as a tag to+the sections that lack it. It adds tags only and never renames, and it disappears once+there is nothing left to add. Every one of these is one gesture, one Ctrl+Z (and one+redo), however many sections it touches, and one changelog batch: a `phase.tags` event+per section changed, `via: "setup"`, and `undo` or `redo` for those. The markdown log+words each line by direction: "tagged X with A, B" for an addition, "untagged X: A" for a+removal, "retagged X: +A, -B" for both, and "cleared the tags on X" when none are left.+A rename moves a reader's tag filter with it, and its Ctrl+Z moves the filter back. Escape closes any+open row editor on the page (Edit, Remove, the palette, a tag rename) wherever the focus+is.++Tags have no colour of their own; colour on this chart means the section. In the chart,+a section header shows its tags as quiet outlined chips after the aside, and they are the+last thing to give way: a long aside ellipsises first, then the name, never the chips.+Section tags are in the JSON export and the snapshot; a CSV is rows, so its `tags`+column carries row tags only. In the table,+hovering a row's Phase cell names its section's tags, and the text filter matches them.+ ## uid: the one handle worth quoting Every item carries a `uid`, a zero-padded number assigned once and never changed.@@ -661,7 +691,7 @@ is why `project.nextUid` can be higher than any uid you can see. ## Tests -`node test/ui_test.mjs` (from the package directory) is the browser suite, added in 0.57.0 and built to the app-testing skill. It spawns `server.js --no-auth` on a scratch port (8960 up) against a SEEDED synthetic plan in a temp dir (20 sections, 123 rows, 21-Sep-2026 to 14-Jan-2027), pins the page's clock to 29-Sep-2026, and drives Chrome from `~/.cache/puppeteer` through the vendored `test/node_modules/puppeteer-core` (`cd test && npm install` once; it does not ship). It asserts effects: the header readouts against an oracle computed from the plan the SERVER saved after each edit (drag, undo, redo, auto-schedule, table edit, delete, paste, create, start shift), the start date at every grain, text size across a refresh in the live app, in a second tab and in an exported snapshot, collapse all in both lenses and across a refresh, and the wrapped legend with the sticky axis, pinned section header and table header measured at 100% and 150% text and across a resize. A coverage manifest fails on any control nobody declared, on any declared control that never renders, and clicks every safe control. 393px, 1440px and 1920px are rendered with no sideways scroll allowed, and a header sweep (every 40px from 360 to 2000 plus 15 named widths; `SWEEP_STEP=20` for denser) asserts both readouts are whole and every header control is on screen, with and without an appName, at 100% and 150% text. Since 0.58.0 the sweep covers all four lenses, and the viewport matrix renders Analytics and Setup too (Setup with 20 sections, plain and with a rename and a remove form open). The `sections` flow drives the Setup page on a plan with rows in every section and no phase uids: after every add, reorder and remove, after each one's undo and redo, and after a reload, it compares the whole section list and every row's section with `/export/json` and checks the page repainted to match; it also covers the one-undo Save, duplicate names refused, Enter pressed five times making one section, an open Remove form surviving Ctrl+Z, the row chords doing nothing on Setup, the chart axis after adding a section, and rename, reorder and remove in an exported snapshot. `node test/server_test.mjs` is the server's own suite (ports 8940 up). Ports are checked free before use and the server answering /health must be the one the suite spawned. `--keep` leaves screenshots in `test/out/`; `--plan <file>` also runs the header and legend checks against a real plan, COPIED to a temp dir first. It exits non-zero on any failure.+`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 scratch ports from a fixed range, 8960-8979 (`UI_TEST_PORTS=<lo>-<hi>` for another; it fails loudly when none is free and never strays outside, so other tools can safely be given ports beyond it), against a SEEDED synthetic plan in a temp dir (20 sections, 123 rows, 21-Sep-2026 to 14-Jan-2027), pins the page's clock to 29-Sep-2026, and drives Chrome from `~/.cache/puppeteer` through the vendored `test/node_modules/puppeteer-core` (`cd test && npm install` once; it does not ship). It asserts effects: the header readouts against an oracle computed from the plan the SERVER saved after each edit (drag, undo, redo, auto-schedule, table edit, delete, paste, create, start shift), the start date at every grain, text size across a refresh in the live app, in a second tab and in an exported snapshot, collapse all in both lenses and across a refresh, and the wrapped legend with the sticky axis, pinned section header and table header measured at 100% and 150% text and across a resize. A coverage manifest fails on any control nobody declared, on any declared control that never renders, and clicks every safe control. 393px, 1440px and 1920px are rendered with no sideways scroll allowed, and a header sweep (every 40px from 360 to 2000 plus 15 named widths; `SWEEP_STEP=20` for denser) asserts both readouts are whole and every header control is on screen, with and without an appName, at 100% and 150% text. Since 0.58.0 the sweep covers all four lenses, and the viewport matrix renders Analytics and Setup too (Setup with 20 sections, plain and with a rename and a remove form open). The `sections` flow drives the Setup page on a plan with rows in every section and no phase uids: after every add, reorder and remove, after each one's undo and redo, and after a reload, it compares the whole section list and every row's section with `/export/json` and checks the page repainted to match; it also covers the one-undo Save, duplicate names refused, Enter pressed five times making one section, an open Remove form surviving Ctrl+Z, the row chords doing nothing on Setup, the chart axis after adding a section, and rename, reorder and remove in an exported snapshot. The `tags` flow (0.59.0) does the same for section tags on a plan whose last section arrives with a messy tag list: every add, comma-add, remove, rename-everywhere, remove-everywhere and prefix pass is checked against `/export/json` and the page, with its undo and redo, the `phase.tags` changelog lines (one batch, `via: setup`, and the undo and redo lines), a reload, the legend's tag chips and fold, the filter's row counts in the chart and the table, its composition with the section chips and Collapse all, its survival across a reload, a removed tag leaving it, the widths 393 and 1440, and tagging and filtering in an exported snapshot; the coverage gate walks a tagged copy of the seeded plan. `node test/server_test.mjs` is the server's own suite (ports 8940 up). Ports are checked free before use and the server answering /health must be the one the suite spawned. `--keep` leaves screenshots in `test/out/`; `--plan <file>` also runs the header and legend checks against a real plan, COPIED to a temp dir first. It exits non-zero on any failure. ## Example
docs/BUILD-NOTES.md+99@@ -3,6 +3,105 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-09-30 (0.59.0, tags on sections, the client)++Checkpoint 2 of three. The server already stored, merged, exported and logged+`phases[].tags` (0.58.0); this is the page. It reads them with a `normalizeTags` that is the+server's line for line (trim, drop non-strings and empties, case-blind de-dupe keeping the+first spelling, 32 characters, 12 per list), so a messy data file shows exactly what the+server will store. `getState()` writes `tags` on EVERY phase entry, `[]` included: to the+server an entry without the key means "keep the data file's", which protects a 0.58.0 tab+and would stop this one from ever clearing a section's last tag. Items and created rows+carry `tags` too, when a row has a list, so 0.60.0 has nothing to add on the save path.+`applyState` inherits a section's tags from the data file by uid, then name, exactly as+the server's merge does. The client's JSON export carries section tags and row tags; its+CSV carries a `tags` column for ROW tags only, in the server's position, since a CSV is+rows and section tags are JSON only.++**Setup.** A tags line under each section (chips with an x, a box that adds on Enter or+a comma, suggestions from a datalist of the plan's tags) and a Tags card (every tag,+how many sections carry it, Rename and Remove everywhere, and the name-prefix button when+it has work). Everything goes through one function, `editTags`, which pushes ONE undo+entry of a new type, `phase.tags` (the lists as they were, keyed by `phaseKey`), and one+save. It does not reuse `phaseSnapshot`: that restores the collapse set and the legend+visibility too, so undoing a tag would also have undone a lane you folded since. A typed+tag and the focus in its box survive every repaint of the page, which repaints on each+save, including the one the tag just caused.++**The changelog is a diff, not a post.** `phase.tags` lines come from `diffAndLog`, like+row edits, rather than from `logPhaseEvent`, like the other section edits. Two things+follow: an undo and a redo of a tag edit are logged (`via: undo/redo`), which the other+section edits are not, and a gesture across many sections goes out as one POST behind its+save, which is "one changelog batch" by construction.++**The legend.** With tags in the plan, the tag chips lead (after Collapse all) and the+section chips fold behind one "20 sections" chip; choosing tags brings back the chips of+exactly the sections on screen. That is what makes a 20-section legend easier with tags+rather than busier: on the instaPCB plan the default legend is four tag chips and a fold,+where it was twenty dots. A section chip behind a chosen tag that is its name prefix drops+the repeat. A plan with no tags is untouched (the legend and viewport groups still see+twenty chips). The filter hides a lane outright, header included, through the class and+flag a collapsed row already uses, so the arrows, the caps and the tail room follow with+no new code; Collapse all counts only the lanes on screen. `prefs.tagFilter` is checked+against the plan only once the sidecar is in (`tagsReady`), or a tag the sidecar adds+would be pruned from the filter on every load.++**Two fixes from the 0.58.0 verifier.** Escape now closes any open Setup row editor+wherever the focus is, from one capture listener, because the focus could sit where+neither the field's own handler nor the app's Escape stack saw it (the new-section box:+the blur-on-Escape rule took the key). And a toast pauses only for a pointer that MOVED+onto it: Chrome sends mouseenter when a toast appears under a resting pointer, which+stopped the clock for good, and that is the "Undid change" toast that stayed up across+later screens. A paused toast also goes after 15 seconds regardless.++Tests: a `tags` flow (84 checks) on a six-section plan whose last section arrives with a+messy tag list; the coverage gate now walks a tagged copy of the seeded plan, with three+new states (a tag rename open, the section chips unfolded, a tag filter on) and every new+control declared by id or pinned by a data attribute. Two existing checks had leaned on+old behaviour. `live: delete` waited for the undo toast and got past the "saved" pill+over the last row's delete button only because the toast used to stick until the pointer+moved; it now waits for the pill too. And `clickMilestone` clicked the diamond's hit strip,+which has no box in line style; the walk cycles the milestone style as it clicks, and the+three new states shifted where that cycle stood when the milestone state came round, so it+now clicks whichever of the strip or the rule's cap is on screen.++### Review fixes (0.59.0)++- **The filter now follows a tag rename.** The rename's "move the filter along" step ran+ after `editTags`, whose prune had already dropped the old name, so it never fired: filter+ on Launch, rename it Go, and the filter was empty. `editTags` now takes the new filter as+ part of the gesture and sets it before the prune, and the undo entry carries the filter as+ it was, so Ctrl+Z moves it back to Launch and redo on to Go.+- **Header tag chips are never the thing cut off.** Name, aside and chips were inline text+ inside one ellipsis, so a long aside pushed the chips out of the label column on six of+ the instaPCB plan's sections. They are three flex items now. The aside starts at zero+ width and grows into what is left, up to its own width, so it alone gives way; a shrink+ ratio would have taken a sliver from the name as well, and a sliver is enough for an+ ellipsis. The chips never shrink. Checked at 1440 on the real plan with its 380px column.+- **The changelog words a tag line by direction** (`server.js`, wording only): "tagged X+ with ..." for an addition (unchanged), "untagged X: A (now ...)" for a removal,+ "retagged X: +A, -B (now ...)" for both, and "retagged X as ..." for a change of case.+ A removal used to read "tagged X with <what was left>", like an addition.+- **The Tags card is above the Sections card.** It is short and the section list is long,+ so under twenty rows the prefix button was easy to miss.+- **Clicking away commits a typed tag**, as a table cell does; Escape still discards it.+ The commit waits for the focus to land, so the repaint it causes keeps the focus where it+ went, and a press on the same section's chip x does not commit (the repaint would eat the+ click).+- **Docs:** section tags are in the JSON export and the snapshot only; the CSV `tags` column+ is row tags. The notes above said the CSV carried them.+- **The suite's ports are a fixed range**, 8960-8979 by default or `UI_TEST_PORTS=lo-hi`,+ scanned from the bottom each time and failing loudly when full. It used to walk upward+ with no real bound and once drove a reviewer's server on 8972. The range is twenty and not+ ten because a full run starts about fifteen servers.+- **Not done:** one summary line for the prefix button. The server renders each+ `phase.tags` event as its own line, and a summary would be a new action, which is more+ than a wording change. It is still one POST and consecutive revs.+- **Looked at, predates 0.59.0:** with Collapse all on, the first section has a taller band+ under its header. That band holds the today marker and the milestone caps, and 0.58.0+ draws it the same way. The label rows and the bar rows are aligned in both versions,+ measured top for top.+ ## Build notes, 2026-09-30 (0.58.0, text size on a sandboxed snapshot) Drew reported the text size still resetting on refresh, on the snapshot published at
docs/plan-0.58-setup-and-tags.md+56@@ -146,3 +146,59 @@ Built as planned, with these changes to the plan's assumptions: should expect that a section name identifies one section. - One gesture, one undo, via `withOneUndo`: checkpoint 2's "rename a tag everywhere" is the obvious next user of it.++## Build notes - 2026-09-30, checkpoint 2++Built as planned, with these changes to the plan's assumptions:++- **"The Tags block counts sections (later, rows)" and the legend stand-in were designed+ together.** A tag chip in the legend stands in front of its sections literally: with+ tags in the plan the section chips fold behind one "N sections" chip (open it to reach+ them all, remembered as `prefs.legendSecs`), and choosing tags brings back the chips of+ exactly the sections on screen. The plan said "consider"; without the fold, tag chips+ would have made a 20-chip legend a 24-chip one.+- **The tag filter HIDES sections; the section chips still only dim.** "Shows only the+ sections carrying any of them" read as hide, and hiding is what makes the filter worth+ having on a 20-section plan. It reuses the collapsed-row class and flag, header included,+ so geometry needed no new code. The section chips keep their dim-in-chart,+ drop-from-table behaviour inside the filter.+- **The section filter is NOT persisted today** (`phaseVis` is in memory), though the+ plan said the tag filter should be "like the section filter". The tag filter is+ persisted (`prefs.tagFilter`), as the brief asked; the section chips were left as they+ are.+- **Changelog lines are diffed, not posted.** `phase.tags` comes out of `diffAndLog` at+ save time rather than `logPhaseEvent`, so undo and redo are logged too and a multi-section+ gesture is one POST. Its `via` is `setup`, not the `sections` the other section edits use.+- **A new undo type, `phase.tags`,** instead of `phaseSnapshot`, which would also have+ rolled back the collapse set and the legend visibility.+- **The prefix is the text before the first " · "** (spaces both sides). Other separators+ (" - ", ":") were left out on purpose: "Phase 1 - Design" would have made "Phase 1" a tag.++Notes for checkpoint 3 (rows):+- `getState()` already writes `items[].tags` when a row has an array and `added[]` carries+ `tags`; `applyState` reads `sv.tags`. Clearing a row's tags should leave `r.tags = []`+ (not delete the key) so the sidecar says "cleared".+- `phaseShown(i)` is the ONE predicate the chart (`applyCollapse`) and the table+ (`tableRows`) use. Effective row tags means a per-ROW predicate; replace the per-lane+ checks there with one `rowShown(r)` (own tags plus `phaseTagsOf(r)`), and decide whether a+ section header shows when only some of its rows match (probably yes, with those rows).+- `planTags()` counts sections; the Tags card and the legend chip counts read it. Add a+ row count beside it rather than changing what `n` means, since the fold and the prefix+ logic rely on it being per section.+- `diffAndLog` already has the row side of the server's `tags` event to fill in+ (`snapSemantic().byId` has no tags yet).+- The client CSV already has the `tags` column in the server's position.++## Build notes - 2026-09-30, checkpoint 2 review fixes++- The Tags card moved ABOVE the Sections card (it is short, the list is long); the plan+ said "a Tags block" without a place.+- A rename carries the reader's tag filter with it, and its undo carries it back: the undo+ entry for a rename holds the filter as it was.+- Header chips beat the aside for room; the name comes second, the aside last.+- The changelog's tag lines are worded by direction (tagged, untagged, retagged).+- Commit on blur for the tag box. Escape discards.+- The prefix button still writes one `phase.tags` line per section; a summary line needs a+ new server action, left for later.+- For checkpoint 3: `editTags(change, refilter)` is the path for any tag gesture that must+ also move the filter; a row-tag rename in the Tags card should go through it too.
gantt.html+547−41@@ -1179,7 +1179,13 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 move. Both columns hide the same rows or they drift apart as you scroll. */ .label-row.row-collapsed, .bar-row.row-collapsed, .ms-line.row-collapsed { display: none !important; } .label-row.group { position: relative; padding-right: 4px; }-.label-row.group .lane-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.label-row.group .lane-name { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; display: flex; align-items: baseline; }+.label-row.group .lane-title { flex: 0 1 auto; min-width: 2.5em; overflow: hidden; text-overflow: ellipsis; }+/* The aside starts from nothing and grows into what is left, up to its own width, so it is+ the only thing that gives way. A shrink ratio would take a sliver from the name too, and+ a sliver is enough to put an ellipsis on it. */+.label-row.group .lane-lead { flex: 1 1 0; max-width: max-content; min-width: 0; overflow: hidden; text-overflow: ellipsis; }+.label-row.group .lane-tags { flex: 0 0 auto; } /* ── Setup: the plan's own furniture, on a page of its own (0.58.0) ── Until 0.57.1 the sections were managed from a modal behind a header button. Drew asked for them to have their own page instead, and a page is the better home for@@ -1239,6 +1245,52 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .sec-chip.on { border-color: var(--text); } .sec-msg { flex: 1 1 100%; color: #f85149; font-size: 11px; padding-top: 2px; } .sec-err { color: #f85149; font-size: 11px; margin: -4px 0 8px; }+/* Tags (0.59.0). One chip shape everywhere a tag is shown: a quiet outlined pill in the+ text colours, never a section colour, because colour on this chart means the section. */+.sec-tag, .lane-tag { display: inline-flex; align-items: center; gap: 2px; border: 1px solid var(--border); border-radius: 4px; color: var(--text2); font-family: 'Satoshi', sans-serif; white-space: nowrap; line-height: 1.35; }+.sec-tag { font-size: 11px; padding: 0 2px 0 6px; background: var(--surface); }+.tag-row .sec-tag { padding: 0 6px; }+.sec-tags { order: 8; width: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 0 0 0 25px; margin-top: -2px; min-width: 0; }+.sec-tag-x { background: none; border: none; color: var(--text3); cursor: pointer; font-size: 13px; line-height: 1; padding: 0 3px; border-radius: 3px; font-family: inherit; }+.sec-tag-x:hover { color: #f85149; background: var(--row-hover); }+.setup-view input[type="text"].sec-tag-in { width: 92px; min-width: 0; font-size: 11px; line-height: 1.35; padding: 0 6px; border: 1px dashed transparent; border-radius: 4px; background: transparent; color: var(--text2); }+.sec-row:hover input[type="text"].sec-tag-in { border-color: var(--border); }+.setup-view input[type="text"].sec-tag-in:focus { width: 180px; border-style: solid; border-color: var(--accent); background: var(--bg); color: var(--text); }+.setup-view input[type="text"].sec-tag-in[disabled] { width: auto; flex: 1 1 200px; opacity: 0.6; }+.sec-tag-in::placeholder { color: var(--text3); }+.tag-prefix { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0 0 10px; padding: 8px 10px; border: 1px dashed var(--border); border-radius: 7px; font-size: 11.5px; color: var(--text2); }+.tag-prefix .tp-say { flex: 1 1 240px; min-width: 0; }+.tag-prefix .de-btn { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.tag-list { padding: 2px 0 0; }+.tag-row { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px; padding: 5px 8px; border-radius: 7px; border: 1px solid transparent; }+.tag-row + .tag-row { border-top-color: rgba(48,54,61,0.15); border-top-left-radius: 0; border-top-right-radius: 0; }+.tag-row:hover { background: var(--row-hover); }+.tag-row.tag-editing { border-color: var(--border); background: var(--elevated); border-radius: 7px; }+.tag-name { flex: 1 1 160px; min-width: 0; overflow: hidden; }+.tag-n { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2); white-space: nowrap; }+.tag-acts { display: flex; gap: 2px; flex-shrink: 0; }+.tag-btn { background: none; border: 1px solid transparent; color: var(--text2); cursor: pointer; border-radius: 5px; padding: 3px 7px; font-size: 11px; font-family: inherit; }+.tag-btn:hover { background: var(--surface); color: var(--text); border-color: var(--border-muted); }+.tag-btn.danger:hover { color: #f85149; }+.tag-ren { display: flex; gap: 6px; width: 100%; flex-wrap: wrap; padding: 4px 0 0; }+.tag-ren .tag-nm { flex: 1 1 180px; min-width: 0; }+.tag-ren .de-btn { padding: 2px 10px; }+.tag-empty { font-size: 11.5px; color: var(--text3); padding: 4px 8px; }+/* In the chart: after the aside, smaller, and quieter still. */+.lane-tags { display: inline-flex; gap: 3px; margin-left: 7px; vertical-align: 1px; }+.lane-tag { font-size: 0.76em; font-weight: 400; padding: 0 5px; color: var(--text3); }+/* The legend's tag chips lead the line, before the sections they stand in front of.+ Outlined and hash-marked rather than dotted, so they never read as a section. */+.legend-item.tag-chip, .legend-item.sec-toggle, .legend-item.tag-clear { background: none; border: 1px solid var(--border); font-family: 'Satoshi', sans-serif; color: var(--text2); padding: 2px 8px; gap: 4px; }+.legend-item.tag-chip .tc-hash { color: var(--text3); }+.legend-item.tag-chip .tc-n { font-family: 'JetBrains Mono', monospace; font-size: 0.9em; color: var(--text3); }+.legend-item.tag-chip:hover, .legend-item.sec-toggle:hover, .legend-item.tag-clear:hover { color: var(--text); border-color: var(--accent); background: var(--row-hover); }+.legend-item.tag-chip.on { color: var(--text); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }+.legend-item.tag-chip.on .tc-hash, .legend-item.tag-chip.on .tc-n { color: var(--accent); }+.legend-item.sec-toggle { border-style: dashed; }+.legend-item.sec-toggle svg { width: calc(11px * var(--fs)); height: calc(11px * var(--fs)); flex-shrink: 0; transition: transform 0.15s; }+.legend-item.sec-toggle.open svg { transform: rotate(180deg); }+.legend-item.tag-clear { border-color: transparent; color: var(--text2); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 3px; } /* A phone: the two numbers drop under the name, indented to line up with it, and the row actions follow them on the same line, so a 20-section plan is still one short row per section rather than a column of wrapped buttons. The header row goes,@@ -1487,6 +1539,12 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va it too; the section list inside it is rebuilt from phases[] by renderSections. --> <div class="setup-view" id="setupView"> <div class="su-scroll" id="suScroll"><div class="su-stack">+ <div class="an-card su-card" id="suTags">+ <div class="su-head"><h3>Tags</h3><span class="su-n" id="tagTotal"></span></div>+ <p class="an-sub">Short labels on sections, for finding and filtering them: choose one in the legend to see only the sections that carry it. They have no colour of their own. Add them on each section below; rename or remove one everywhere here.</p>+ <div class="tag-prefix" id="tagPrefix" style="display:none"></div>+ <div class="tag-list" role="list"></div>+ </div> <div class="an-card su-card" id="suSections"> <div class="su-head"><h3>Sections</h3><span class="su-n" id="secTotal"></span></div> <p class="an-sub">The lanes of this plan, in the order the chart and the table show them. Every change here is one Ctrl+Z.</p>@@ -1502,6 +1560,7 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va </div> </div> <datalist id="ownerList"></datalist>+<datalist id="tagList"></datalist> <div class="tip" id="tip"></div> <div class="editor" id="editor"></div> <div class="dropdown" id="exportMenu"></div>@@ -1538,6 +1597,40 @@ const importFileEl = $('importFile'); const projData = await fetch('data').then(r => r.json()); const { project, phases, items } = projData; +// ── Tags (0.59.0) ──+//+// Short strings on a section (phases[].tags) and, from 0.60.0, on a row (items[].tags).+// ONE normaliser, the server's normalizeTags line for line, because a list the page+// shows has to be the list the server will store: trim, drop empties and anything that+// is not a string, de-duplicate without regard to case (the first spelling wins), cap+// each tag at 32 characters and the list at 12. An array in gives an array out, possibly+// empty; anything else gives undefined, and a malformed value is dropped, never an error.+// Tags carry no colour: colour on this chart means the section, and only that.+const TAG_MAX_LEN = 32, TAG_MAX_COUNT = 12;+function normalizeTags(v) {+ if (!Array.isArray(v)) return undefined;+ const out = [], seen = new Set();+ for (const t of v) {+ if (typeof t !== 'string') continue;+ const s = t.trim().slice(0, TAG_MAX_LEN).trim();+ if (!s || seen.has(s.toLowerCase())) continue;+ seen.add(s.toLowerCase());+ out.push(s);+ if (out.length >= TAG_MAX_COUNT) break;+ }+ return out;+}+// The data file's rule: a non-empty list or no key at all. A snapshot or a data file+// written by hand goes through here too, so the page never holds a list the server+// would have rewritten.+function tidyTags(o){+ if(!o||typeof o!=='object'||!('tags' in o))return;+ const t=normalizeTags(o.tags);+ if(t&&t.length)o.tags=t; else delete o.tags;+}+phases.forEach(tidyTags);+items.forEach(tidyTags);+ // The row's key, everywhere: in the saved order, in the deletion list, in every // override, and in the changelog. It is the uid, which is a number the server // assigned once and that nothing can change.@@ -1786,7 +1879,7 @@ function savedPrefs(){ const FS_STEPS = [0.85, 0.925, 1, 1.1, 1.2, 1.35, 1.5]; const prefs = Object.assign( { theme: 'dark', msStyle: 'line', granularity: 'month', showToday: true, view: 'gantt', fs: 1, collapsed: [],- critMode: 'off' },+ critMode: 'off', tagFilter: [], legendSecs: false }, viewDefaults(), savedPrefs() );@@ -1923,6 +2016,13 @@ function laneRows(ph){ return items.filter(r=>r.type!=='group'&&r.phase===ph); } function applyCollapse(){ items.forEach(r=>{ if(r.type==='group'){+ // A lane the tag filter leaves out goes whole, header included, through the same+ // class and flag a collapsed row uses, so every piece of geometry that already+ // skips a collapsed row (the arrows, the caps, the tail room) skips it too.+ const out=!phaseShown(r.phase);+ r._collapsed=out;+ if(r._lDiv)r._lDiv.classList.toggle('row-collapsed',out);+ if(r._bDiv)r._bDiv.classList.toggle('row-collapsed',out); const on=isCollapsed(r.phase); if(r._laneBtn){ r._laneBtn.classList.toggle('collapsed',on);@@ -1933,7 +2033,7 @@ function applyCollapse(){ } return; }- const hide=isCollapsed(r.phase);+ const hide=isCollapsed(r.phase)||!phaseShown(r.phase); r._collapsed=hide; if(r._lDiv)r._lDiv.classList.toggle('row-collapsed',hide); if(r._bDiv)r._bDiv.classList.toggle('row-collapsed',hide);@@ -1955,7 +2055,9 @@ function applyCollapse(){ // 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); }+// Only the lanes on screen vote: with a tag filter on, "Collapse all" is about the+// sections the reader is looking at, and the hidden ones must not pin it on either label.+function lanesWithRows(){ return phases.map((p,i)=>i).filter(i=>phaseShown(i)&&laneRows(i).length>0); } function allLanesCollapsed(){ const l=lanesWithRows(); return l.length>0&&l.every(isCollapsed); } function syncCollapseAllBtn(){ if(!collapseAllBtn.isConnected)return;@@ -1969,8 +2071,11 @@ function syncCollapseAllBtn(){ :`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));+ // With a tag filter on, "all" is the sections on screen: the hidden ones keep whatever+ // they were, so clearing the filter does not reveal lanes folded by a press that never+ // showed them.+ if(tagFilterOn())phases.forEach((p,i)=>{ if(phaseShown(i)){ if(on)collapsed.add(i); else collapsed.delete(i); } });+ else { collapsed.clear(); if(on)phases.forEach((p,i)=>collapsed.add(i)); } prefs.collapsed=[...collapsed]; savePrefs(); applyCollapse(); layoutMsCaps();sizeTailroom();drawDeps();@@ -3113,6 +3218,20 @@ function applySnapshot(sn){ // A list of snapshots applied as one. The inverse is the inverses in reverse, so // undoing a batch and redoing it both land exactly where they started. if(sn.type==='phase.state')return restorePhaseState(sn);+ // Section tags: [phaseKey, tags] pairs, the lists as they were. Found by the section's+ // client identity, never by index or name, so a reorder or a rename in between does+ // not send the tags to the wrong section. A section that is gone is skipped.+ if(sn.type==='phase.tags'){+ const inv={type:'phase.tags',list:[]};+ (sn.list||[]).forEach(([k,t])=>{ const j=phaseIndexOfKey(k); if(j<0)return;+ const p=phases[j]; inv.list.push([k,(p.tags||[]).slice()]);+ if(t&&t.length)p.tags=t.slice(); else delete p.tags; });+ if(!inv.list.length)return null;+ // A rename moved the reader's filter with it; its undo and redo move it back and on.+ if(sn.filter){ inv.filter=prefs.tagFilter.slice(); prefs.tagFilter=sn.filter.slice(); savePrefs(); }+ afterTagsChange();+ return inv;+ } if(sn.type==='phase.rename'){ const p=phases[sn.i]; if(!p)return null; const inv={type:'phase.rename',i:sn.i,name:p.name};@@ -3233,7 +3352,7 @@ function isTypingTarget(el){ const ESC_TEXT_TYPES={text:1,search:1,number:1,tel:1,url:1,email:1,password:1, date:1,month:1,week:1,time:1,'datetime-local':1}; // The fields whose Escape is already spoken for. See the listener in wireGlobalKeys.-const ESC_OWNED='.label-edit,.bar-note-edit,.cell-input,.cl-new-inp,.id-box,.sec-row,#editor';+const ESC_OWNED='.label-edit,.bar-note-edit,.cell-input,.cl-new-inp,.id-box,.sec-row,.tag-row,#editor'; function isEscBlurTarget(el){ if(!el||!el.tagName)return false; const t=el.tagName.toUpperCase();@@ -3308,6 +3427,7 @@ document.addEventListener('mousedown',()=>{ if(delArm)disarmDelete(); if(depArm) // .cl-new-inp the owner list's type-in row Escape closes the list // .id-box the copy-IDs fallback box Escape dismisses the box // .sec-row a Setup page row editor Escape cancels the row's edit+// .tag-row a Setup page tag's rename box Escape cancels the rename // #editor every panel field Escape closes the panel // // CAPTURE, and it stops the event dead. One Escape is one action: blurring the filter box@@ -3575,14 +3695,23 @@ function getState(){ const o={uid:r.uid,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r),name:r.name,phase:r.phase}; if(r.type==='milestone')o.date=r.date; else { o.s=r.start; o.e=r.end; o.deps=r.deps||[]; o.note=r.note||''; }+ // A row's own tags (0.60.0 edits them). Written only when the row has a list, so a+ // row that never had one says nothing and the data file's value stands; an edit+ // that clears a row's tags leaves `[]` on it, which is the sidecar's "cleared".+ if(Array.isArray(r.tags))o.tags=r.tags.slice(); return o; }), // The whole section list, not just date patches by index. Sections are data // the user edits now (rename, reorder, and soon colour and lead), so the // sidecar has to carry what they are, not only when they run. `i` stays for a // reader written against the older shape.+ // `tags` is written on EVERY entry, `[]` included. This page owns the whole list,+ // and to the server an entry with no `tags` key means "keep the data file's", which+ // is right for a 0.58.0 client that knows nothing of tags and wrong for this one: a+ // section whose last tag was removed here has to say so. phases: phases.map((p,i)=>{const o={i,name:p.name,start:p.start,end:p.end};- if(p.uid)o.uid=p.uid; if(p.lead)o.lead=p.lead; if(p.color)o.color=p.color; return o;}),+ if(p.uid)o.uid=p.uid; if(p.lead)o.lead=p.lead; if(p.color)o.color=p.color;+ o.tags=(p.tags||[]).slice(); return o;}), order: items.filter(r=>!isDerivedGroup(r)).map(rowOrderKey), deleted: [...deletedKeys], added: addedItems.map(r=>{const o={};@@ -3591,7 +3720,7 @@ function getState(){ // does not survive the next save: leaving uid out would have quietly // stripped the durable id off every row the user ever created, and in v4 it // is also the key every other part of the file uses to name that row.- ['type','uid','id','name','start','end','date','phase','owner','status','description','deps','note','striped','provenance'].forEach(k=>{if(r[k]!==undefined)o[k]=r[k];});+ ['type','uid','id','name','start','end','date','phase','owner','status','description','deps','note','striped','provenance','tags'].forEach(k=>{if(r[k]!==undefined)o[k]=r[k];}); return o;}), }; }@@ -3636,6 +3765,8 @@ function applyState(state){ if(sv.status!==undefined)setOpt(r,'status',sv.status); if(sv.desc!==undefined)setOpt(r,'description',sv.desc); if(sv.prov!==undefined)setProv(r,sv.prov);+ // Absent: the data file's tags stand. [] : the user cleared them. As the server merges.+ if(Array.isArray(sv.tags)){ const t=normalizeTags(sv.tags); r.tags=t; } if(sv.name)r.name=sv.name; if(Number.isInteger(sv.phase)&&sv.phase>=0&&sv.phase<phaseN)r.phase=sv.phase; });@@ -3651,11 +3782,19 @@ function applyState(state){ const sph=state.phases; let phasesRebuilt=false; if(Array.isArray(sph)&&sph.length&&sph.some(p=>p&&p.name!==undefined)){+ // Tags are newer than this shape. An entry with no `tags` key was written by a client+ // that predates them, and it inherits its section's tags from the data file, found by+ // uid and then by name, exactly as the server's merge does; `[]` clears them.+ const basePh=phases.slice();+ const baseTags=p=>{ const b=(p.uid&&basePh.find(x=>x&&x.uid===p.uid))||basePh.find(x=>x&&x.name===p.name);+ return b&&Array.isArray(b.tags)?b.tags:undefined; }; const rebuilt=sph.map(p=>{ const o={name:p.name,start:p.start||'',end:p.end||''}; if(p.uid)o.uid=p.uid; if(p.lead)o.lead=p.lead; if(p.color)o.color=p.color;+ const t=Array.isArray(p.tags)?normalizeTags(p.tags):baseTags(p);+ if(t&&t.length)o.tags=t.slice(); return o; }); phases.length=0; phases.push(...rebuilt);@@ -3668,7 +3807,8 @@ function applyState(state){ if(rowsBuilt)buildDerivedHeaders(); phasesRebuilt=true; } else {- (sph||[]).forEach(sv=>{ const p=phases[sv.i]; if(p){ if(sv.start)p.start=sv.start; if(sv.end)p.end=sv.end; } });+ (sph||[]).forEach(sv=>{ const p=sv&&phases[sv.i]; if(p){ if(sv.start)p.start=sv.start; if(sv.end)p.end=sv.end;+ if(Array.isArray(sv.tags)){ const t=normalizeTags(sv.tags); if(t.length)p.tags=t; else delete p.tags; } } }); } // Before the order, always: the saved order was written after these rows were // removed, so applying it first would compare against the wrong length and be@@ -3719,7 +3859,11 @@ function snapSemantic(){ // (both sides early-return on an unseen name) and instead surfaced as a phantom // "reorder". It also made one of any same-named pair invisible to the diff. const ms={};items.filter(r=>r.type==='milestone').forEach(r=>{ms[rowOrderKey(r)]={name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r),phase:r.phase};});- return {byId,ms,order:items.map(rowOrderKey)};+ // Section tags, keyed by the section's client identity (phaseKey), so a rename or a+ // reorder between two snapshots is not read as a tag change. A section that exists on+ // one side only (created, removed) has its own event already and adds none here.+ const ph={};phases.forEach((p,i)=>{ph[phaseKey(p)]={i,name:p.name,tags:(p.tags||[]).slice()};});+ return {byId,ms,ph,order:items.map(rowOrderKey)}; } // The baseline the changelog diffs against. It has to be taken after the plan is // loaded and normalised, which is an ordering fact, so it is an init step rather@@ -3796,6 +3940,16 @@ function diffAndLog(via){ if((a.prov||'')!==(b.prov||'')) evs.push({action:'provenance',ms:true,key:k,name:nm,from:{label:a.provL||''},to:{label:b.provL||''}}); if(a.phase!==b.phase) evs.push({action:'phase',ms:true,key:k,name:nm,from:{phase:phaseNameAt(a.phase)},to:{phase:phaseNameAt(b.phase)}}); });+ // A section's tags (0.59.0). Diffed here rather than posted by the Setup page, so an+ // undo and a redo of a tag edit are logged too, as every other undo is, and so one+ // gesture that touches many sections (rename a tag everywhere, the prefix button) goes+ // out as ONE batch behind its save.+ const curPh=cur.ph||{},oldPh=lastSnap.ph||{};+ Object.keys(curPh).forEach(k=>{+ const a=oldPh[k],b=curPh[k]; if(!a)return;+ if(JSON.stringify(a.tags)!==JSON.stringify(b.tags))+ evs.push({action:'phase.tags',phase:b.i,name:b.name,from:{tags:a.tags},to:{tags:b.tags}});+ }); const before=lastSnap.order,after=cur.order; // Only a real reorder. Adding or removing a row changes this list, and so does // moving one to another lane, and both of those already say so themselves.@@ -4084,17 +4238,32 @@ function forceSaveMine(){ // at one now stops the clock until you look away, which is what every toast in every // other app does. const TOAST_MS=2800; // general confirmations, was a 1500ms default+// 0.59.0: pointing AT it, not the toast arriving under a pointer that was already+// there. A verifier saw an "Undid change" toast stay up across several later screens:+// Ctrl+Z was pressed with the pointer resting where the toast then appeared, Chrome sent+// mouseenter for the new box under the still pointer, the clock stopped, and a pointer+// that never moves never leaves. So the pause needs the pointer to have MOVED since the+// toast went up, and a paused toast still goes after TOAST_PAUSE_MAX in any case.+const TOAST_PAUSE_MAX=15000;+let lastPtr=null;+document.addEventListener('mousemove',e=>{ lastPtr=e.clientX+','+e.clientY; },{capture:true,passive:true}); function showToast(el,ms,onDone){ clearTimeout(el.__t); el.__ms=ms||TOAST_MS; el.__onDone=onDone||null; el.__paused=false; el.__transient=true;+ el.__ptrAt=lastPtr; if(!el.__hoverWired){ el.__hoverWired=true;- el.addEventListener('mouseenter',()=>{ if(el.classList.contains('show')){ clearTimeout(el.__t); el.__paused=true; } });+ el.addEventListener('mousemove',e=>{+ if(el.__paused||!el.classList.contains('show'))return;+ if(el.__ptrAt===e.clientX+','+e.clientY)return; // the pointer has not moved since the toast appeared+ clearTimeout(el.__t); el.__paused=true;+ if(el.__transient!==false)el.__t=setTimeout(el.__hide,TOAST_PAUSE_MAX);+ }); // A pinned message is not a toast and must not be given a hide timer by the pointer // merely passing over it. Through 0.53.0 the conflict banner lived in this element and // was shown by hand rather than through showToast, so __ms still belonged to whatever // toast came before it and one hover was enough to schedule its disappearance.- el.addEventListener('mouseleave',()=>{ if(el.__paused&&el.classList.contains('show')){ el.__paused=false; if(el.__transient!==false)el.__t=setTimeout(el.__hide,el.__ms); } });+ el.addEventListener('mouseleave',()=>{ if(el.__paused&&el.classList.contains('show')){ el.__paused=false; clearTimeout(el.__t); if(el.__transient!==false)el.__t=setTimeout(el.__hide,el.__ms); } }); // Whatever flashed over the save indicator, the not-saving state is what it goes back // to while saves are still being refused. el.__hide=()=>{ el.classList.remove('show'); if(el.__onDone)el.__onDone(); if(el===saveInd&&saveBlocked)showSaveErr(); };@@ -4106,27 +4275,130 @@ function flash(el,text,ms){ el.textContent=text; showToast(el,ms); } // ── Legend ── const legendEl = $('legend');-function phaseChip(ph,i){+function phaseChip(ph,i,label){ const el=document.createElement('div');el.className='legend-item phase-chip';- el.title=(ph.name||'')+(ph.lead?', '+ph.lead:'');- el.innerHTML=`<div class="legend-dot" style="background:${esc(phaseColor(i))}"></div><span class="chip-label">${esc(ph.name)}</span>`;+ const tg=ph.tags&&ph.tags.length?'\nTags: '+ph.tags.join(', '):'';+ el.title=(ph.name||'')+(ph.lead?', '+ph.lead:'')+tg;+ el.innerHTML=`<div class="legend-dot" style="background:${esc(phaseColor(i))}"></div><span class="chip-label">${esc(label||ph.name)}</span>`; if(!phaseVis[i])el.classList.add('off');- el.addEventListener('click',()=>{phaseVis[i]=!phaseVis[i];el.classList.toggle('off',!phaseVis[i]);applyFilter();});+ el.addEventListener('click',()=>{phaseVis[i]=!phaseVis[i];el.classList.toggle('off',!phaseVis[i]);applyFilter();syncSecToggle();});+ return el;+}+function buildLegendPhases(){ rebuildLegend(); }++// ── Tags: the model, the filter, the legend (0.59.0) ──+//+// A tag is compared without regard to case everywhere (the normaliser already keeps one+// spelling per section), and shown in the spelling the plan uses first.+const tagKey=t=>String(t||'').trim().toLowerCase();+function phaseTagsOf(r){ const p=r&&phases[r.phase]; return (p&&p.tags)||[]; }+// Every tag in the plan, in the order it first appears going down the sections, with+// how many sections carry it. First-appearance order and not A to Z, because on a plan+// whose sections are grouped ("Launch · ...", then "Tech · ...") that IS the plan's order.+function planTags(){+ const m=new Map();+ phases.forEach((p,i)=>(p.tags||[]).forEach(t=>{ const k=tagKey(t);+ if(!m.has(k))m.set(k,{tag:t,key:k,n:0,idx:[]});+ const e=m.get(k); e.n++; e.idx.push(i); }));+ return [...m.values()];+}+// The filter is VIEW state, one person's, kept in prefs beside the collapse set: which+// tags they are looking at is not a fact about the plan. Stored in the plan's spelling,+// compared by key. A tag the plan no longer has drops out (pruneTagFilter), so a filter+// can never be left on that hides everything for a reason nobody can see.+function clampTagFilterPref(){+if(!Array.isArray(prefs.tagFilter))prefs.tagFilter=[];+prefs.tagFilter=prefs.tagFilter.filter(t=>typeof t==='string'&&t.trim());+prefs.legendSecs=prefs.legendSecs===true;+}+let tagsReady=false; // set once the sidecar is in: before that the data file's tags are not the plan's+function tagFilterKeys(){ return new Set(prefs.tagFilter.map(tagKey)); }+function tagFilterOn(){ return prefs.tagFilter.length>0; }+// Is section i on screen as far as the tag filter goes? Any of the chosen tags will do.+function phaseShown(i){+ if(!prefs.tagFilter.length)return true;+ const p=phases[i]; if(!p)return true;+ const ks=tagFilterKeys();+ return (p.tags||[]).some(t=>ks.has(tagKey(t)));+}+function pruneTagFilter(){+ if(!tagsReady||!prefs.tagFilter.length)return false;+ const have=new Map(planTags().map(e=>[e.key,e.tag]));+ const next=[]; prefs.tagFilter.forEach(t=>{ const sp=have.get(tagKey(t)); if(sp&&!next.some(x=>tagKey(x)===tagKey(sp)))next.push(sp); });+ if(JSON.stringify(next)===JSON.stringify(prefs.tagFilter))return false;+ prefs.tagFilter=next; savePrefs();+ return true;+}+// Everything a change in what is shown has to move: the rows, the arrows, the caps, the+// table, the legend's own chips. The same tail Collapse all runs.+function applyTagView(){+ rebuildLegend();+ applyFilter();+ layoutMsCaps();sizeTailroom();drawDeps();+ depHoverResync();+}+function setTagFilter(list){+ prefs.tagFilter=list; savePrefs();+ applyTagView();+}+function toggleTagFilter(tag){+ const k=tagKey(tag), cur=prefs.tagFilter;+ setTagFilter(cur.some(t=>tagKey(t)===k)?cur.filter(t=>tagKey(t)!==k):[...cur,tag]);+}+// The legend with tags in the plan. Twenty section chips are a wall; four tag chips are+// a choice. So when the plan has tags, the tag chips lead, and the section chips stand+// BEHIND them: with no tag chosen they fold away behind one "20 sections" chip (a click+// opens them, and that is remembered), and with tags chosen the chips of exactly the+// sections on screen come back, a handful, straight after the tags that chose them.+// A plan with no tags has no tag chips and no fold: its legend is what it always was.+const secToggle=document.createElement('button'), tagClear=document.createElement('button');+function wireLegendTags(){+secToggle.type='button'; secToggle.id='legendSecs'; secToggle.className='legend-item sec-toggle';+secToggle.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="M9 6l6 6-6 6"/></svg><span class="st-label"></span>';+secToggle.addEventListener('click',()=>{ prefs.legendSecs=!prefs.legendSecs; savePrefs(); rebuildLegend(); });+tagClear.type='button'; tagClear.id='tagClear'; tagClear.className='legend-item tag-clear';+tagClear.textContent='Clear tags';+tagClear.title='Show every section again. Only your view changes, not the plan.';+tagClear.addEventListener('click',()=>setTagFilter([]));+}+function tagChip(e,on){+ const el=document.createElement('button'); el.type='button';+ el.className='legend-item tag-chip'+(on?' on':''); el.dataset.tag=e.key;+ el.setAttribute('aria-pressed',on?'true':'false');+ el.innerHTML=`<span class="tc-hash" aria-hidden="true">#</span><span class="tc-label">${esc(e.tag)}</span><span class="tc-n">${e.n}</span>`;+ el.title=(on?'Stop filtering by ':'Show only the sections tagged ')+e.tag+' ('+plural(e.n,'section')+': '+ +e.idx.map(i=>phases[i].name).join(', ')+'). Choose more than one to see any of them. Your view only, it does not change the plan.';+ el.addEventListener('click',()=>toggleTagFilter(e.tag)); return el; }-function buildLegendPhases(){-phases.forEach((ph,i)=>legendEl.appendChild(phaseChip(ph,i)));+// Does the fold hide a section chip that is switched off? Then the fold says so, or a+// section would be washed out on the chart with nothing in the legend owning up to it.+function syncSecToggle(){+ if(!secToggle.isConnected)return;+ const n=phases.length, off=phaseVis.filter(v=>!v).length, open=!!prefs.legendSecs;+ secToggle.classList.toggle('open',open);+ secToggle.setAttribute('aria-expanded',open?'true':'false');+ secToggle.querySelector('.st-label').textContent=(open?'Fewer':plural(n,'section'))+(!open&&off?' · '+off+' off':'');+ secToggle.title=open?'Fold the section chips away behind the tags again.'+ :'Show a chip for each of the '+plural(n,'section')+', to switch sections on and off one at a time.'; }-// The legend is a view of phases[], so it is rebuilt from phases[] rather than-// 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());+ legendEl.querySelectorAll('.phase-chip,.tag-chip').forEach(el=>el.remove());+ [secToggle,tagClear].forEach(el=>{ if(el.isConnected)el.remove(); }); const first=collapseAllBtn.isConnected?collapseAllBtn.nextSibling:legendEl.firstChild;- phases.forEach((ph,i)=>legendEl.insertBefore(phaseChip(ph,i),first));+ const put=el=>legendEl.insertBefore(el,first);+ const tags=planTags(), ks=tagFilterKeys(), filtering=tags.length>0&&tagFilterOn();+ tags.forEach(e=>put(tagChip(e,ks.has(e.key))));+ if(filtering)put(tagClear);+ const folded=tags.length>0&&!filtering&&!prefs.legendSecs;+ if(tags.length&&!filtering)put(secToggle);+ // Behind a chosen tag, "Tech · Laser workcells" says "Tech" twice: the tag chip in+ // front of it already does. So a section whose name prefix IS a chosen tag shows the+ // rest of its name; its tooltip keeps the whole of it.+ const short=ph=>{ if(!filtering)return ''; const pre=namePrefix(ph.name);+ return pre&&ks.has(tagKey(pre))?String(ph.name).replace(PREFIX_RE_CUT,''):''; };+ if(!folded)phases.forEach((ph,i)=>{ if(phaseShown(i))put(phaseChip(ph,i,short(ph))); });+ syncSecToggle(); syncCollapseAllBtn(); } // A key for the second band colour, and nothing more: the holidays are not something@@ -4322,9 +4594,8 @@ function buildRowDom(r,idx){ if(r.type==='group'){ lDiv.className='label-row group'; const nm=document.createElement('span');nm.className='lane-name';- nm.textContent=isDerivedGroup(r)?(phases[r.phase]?phases[r.phase].name:r.label):groupLabel(r.label);- const ld=isDerivedGroup(r)?((phases[r.phase]&&phases[r.phase].lead)||''):'';- if(ld){ const sp=document.createElement('span'); sp.className='lane-lead'; sp.textContent=ld; nm.appendChild(sp); }+ const dp=isDerivedGroup(r)&&phases[r.phase];+ fillLaneName(nm,isDerivedGroup(r)?(dp?dp.name:r.label):groupLabel(r.label),dp?(dp.lead||''):'',dp?dp.tags:null); const btn=document.createElement('button');btn.className='lane-toggle';btn.type='button'; btn.innerHTML='<span class="lane-count"></span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>'; lDiv.appendChild(nm);lDiv.appendChild(btn);@@ -5883,13 +6154,34 @@ function refreshHeaderRow(r){ if(!r||!r._lDiv)return; const p=phases[r.phase]; if(!p)return; const nm=r._lDiv.querySelector('.lane-name');- if(nm){- nm.textContent=p.name||'';- if(p.lead){ const sp=document.createElement('span'); sp.className='lane-lead'; sp.textContent=p.lead; nm.appendChild(sp); }- }+ if(nm)fillLaneName(nm,p.name||'',p.lead||'',p.tags); r.label=p.name; r.lead=p.lead||''; }+// A section header's tags: small quiet chips after the aside, in the text colour and+// never the section's, because colour on this chart means the section and only that.+// The header's name, aside and tags are three flex items, and they give way in that+// order of LEAST importance: the aside ellipsises first, then the name, and the tag chips+// never shrink. The 0.59.0 review found the chips cut off after a long aside, when all+// three were inline text in one ellipsis. The name is the header's first child, as before.+function fillLaneName(nm,name,lead,tags){+ nm.textContent='';+ const t=document.createElement('span'); t.className='lane-title'; t.textContent=name; nm.appendChild(t);+ if(lead){ const sp=document.createElement('span'); sp.className='lane-lead'; sp.textContent=lead; nm.appendChild(sp); }+ if(tags&&tags.length){+ const w=document.createElement('span'); w.className='lane-tags';+ tags.forEach(x=>{ const c=document.createElement('span'); c.className='lane-tag'; c.textContent=x; w.appendChild(c); });+ nm.appendChild(w);+ }+}+// Any change to a section's tags lands here: the filter drops a tag the plan no longer+// has, and both lenses, the legend and the Setup page repaint from phases[].+function afterTagsChange(){+ pruneTagFilter();+ refreshSections();+ applyTagView();+} function refreshSections(){+ pruneTagFilter(); items.filter(isDerivedGroup).forEach(refreshHeaderRow); rebuildLegend(); applyCollapse();@@ -6160,6 +6452,24 @@ function wireSetup(){ btn.addEventListener('click',add); inp.addEventListener('keydown',e=>{ e.stopPropagation(); if(e.key==='Enter'){ e.preventDefault(); add(); } }); inp.addEventListener('input',()=>say(''));+ // Escape closes whatever row editor is open on this page (Edit, Remove, the colour+ // palette, a tag's rename box), WHEREVER the focus is: in one of its fields, on one of+ // its buttons, on some other control of the page, or nowhere. 0.58.0 left it to each+ // field's own handler plus one rung of the app-level stack, and a verifier found a+ // focus that neither covered (a field outside the row, which the blur-on-Escape rule+ // took first). Capture, so no field's own Escape can come first. A field that belongs+ // to something else on screen, the New panel say, keeps its own Escape.+ document.addEventListener('keydown',e=>{+ if(e.key!=='Escape'||e.altKey||e.ctrlKey||e.metaKey||prefs.view!=='setup')return;+ const a=document.activeElement;+ if(a&&a!==document.body&&!v.contains(a))return;+ const open=secRowOpen(), ren=tagEditing!==null;+ if(!open&&!ren)return;+ e.preventDefault(); e.stopPropagation();+ if(ren){ tagEditing=null; renderTagsCard(); }+ if(open)renderSections();+ if(a&&a!==document.body&&!a.isConnected)try{ document.activeElement.blur(); }catch(_){}+ },true); } // Rebuild the list from phases[]. With `keep`, an open row editor survives the rebuild: // it is found again by its section's phaseKey (NOT by its index, which an undo, a redo@@ -6175,6 +6485,9 @@ function renderSections(keep){ const list=v.querySelector('.sec-list'); let held=null; if(keep){ const ed=list.querySelector('.sec-row.sec-editing'); if(ed&&ed._secHold)held=ed._secHold(); }+ // A half-typed tag survives every repaint, kept or not, and so does the focus in it:+ // the list repaints on every save, including the one the tag just typed caused.+ const tagHold=holdTagInput(list); list.innerHTML=''; const tot=$('secTotal'); if(tot)tot.textContent=phases.length+' section'+(phases.length===1?'':'s'); phases.forEach((p,i)=>{@@ -6191,7 +6504,8 @@ function renderSections(keep){ `<button class="sec-btn" data-a="down" title="Move down"${i===phases.length-1?' disabled':''}>↓</button>`+ `<button class="sec-btn" data-a="edit" title="Rename, or set the aside beside the name">Edit</button>`+ `<button class="sec-btn danger" data-a="del" title="Remove this section"${phases.length<2?' disabled':''}>Remove</button>`+- `</span>`;+ `</span>`+secTagsHtml(p);+ row.dataset.k=phaseKey(p); list.appendChild(row); }); list.querySelectorAll('.sec-row').forEach(row=>{@@ -6212,12 +6526,191 @@ function renderSections(keep){ else if(a==='del'){ renderSections(); openDelete(rowAt(i),i); } })); });+ list.querySelectorAll('.sec-row').forEach(wireSecTags); if(held){ const j=phases.findIndex(q=>phaseKey(q)===held.k); if(j>=0)held.reopen(rowAt(j),j); }+ restoreTagInput(list,tagHold);+ renderTagsCard(); } function rowAt(i){ return $('setupView').querySelector(`.sec-row[data-i="${i}"]`); }++// ── Tags on the Setup page (0.59.0) ──+//+// Every tag edit goes through editTags: one gesture, one undo entry, one save, and one+// changelog batch, however many sections it touches. `change(p,i)` returns a section's+// new list, or nothing to leave it alone. The undo entry is the lists as they were, keyed+// by phaseKey (see applySnapshot 'phase.tags'). The changelog lines come from the diff at+// save time (diffAndLog), tagged `setup`, so an undo and a redo are logged the same way.+function sameTags(a,b){ return JSON.stringify(a||[])===JSON.stringify(b||[]); }+// `refilter` (a rename) returns the reader's tag filter as it should read after the edit.+// It is applied BEFORE the prune, which would otherwise drop the old name first (the+// 0.59.0 review caught exactly that), and the undo entry carries the filter as it was, so+// Ctrl+Z puts the filter back with the tags and redo moves it on again.+function editTags(change,refilter){+ const before=[];+ phases.forEach((p,i)=>{+ const nx=change(p,i); if(!nx)return;+ const t=normalizeTags(nx)||[], cur=p.tags||[];+ if(sameTags(t,cur))return;+ before.push([phaseKey(p),cur.slice()]);+ if(t.length)p.tags=t; else delete p.tags;+ });+ if(!before.length)return 0;+ const sn={type:'phase.tags',list:before};+ if(refilter){+ const nf=refilter(prefs.tagFilter);+ if(JSON.stringify(nf)!==JSON.stringify(prefs.tagFilter)){ sn.filter=prefs.tagFilter.slice(); prefs.tagFilter=nf; savePrefs(); }+ }+ pushUndo(sn);+ afterTagsChange();+ scheduleSave('setup');+ return before.length;+}+function addPhaseTags(k,words){+ const j=phaseIndexOfKey(k); if(j<0)return 0;+ return editTags((p,i)=>i===j?[...(p.tags||[]),...words]:null);+}+function removePhaseTag(k,tag){+ const j=phaseIndexOfKey(k); if(j<0)return 0;+ return editTags((p,i)=>i===j?(p.tags||[]).filter(t=>tagKey(t)!==tagKey(tag)):null);+}+// Rename a tag everywhere, in place in each list so its position is kept. A new name+// that one of those sections already carries merges into it (the normaliser keeps the+// first). The reader's filter follows the rename rather than silently dropping it.+function renameTagEverywhere(from,to){+ to=String(to||'').trim().slice(0,TAG_MAX_LEN).trim(); if(!to)return 0;+ const k=tagKey(from);+ return editTags(p=>(p.tags||[]).some(t=>tagKey(t)===k)?p.tags.map(t=>tagKey(t)===k?to:t):null,+ cur=>{ const f=[]; cur.forEach(t=>{ const v=tagKey(t)===k?to:t; if(!f.some(x=>tagKey(x)===tagKey(v)))f.push(v); }); return f; });+}+function removeTagEverywhere(tag){+ const k=tagKey(tag);+ return editTags(p=>(p.tags||[]).some(t=>tagKey(t)===k)?p.tags.filter(t=>tagKey(t)!==k):null);+}+// "Launch · Program and gates" carries its group in its name. The prefix is what comes+// before the first " · " (spaces on both sides, so a name with a dot in a word is left+// alone). The button adds it as a tag to every such section that lacks it and has room;+// it never renames anything, and it is offered only while there is something to add.+const PREFIX_RE=/^(.+?)\s+·\s+\S/, PREFIX_RE_CUT=/^(.+?)\s+·\s+/;+function namePrefix(name){ const m=String(name||'').match(PREFIX_RE); if(!m)return '';+ const t=m[1].trim(); return t.length<=TAG_MAX_LEN?t:''; }+function prefixCandidates(){+ const out=[];+ phases.forEach((p,i)=>{ const t=namePrefix(p.name); if(!t)return;+ const cur=p.tags||[]; if(cur.length>=TAG_MAX_COUNT||cur.some(x=>tagKey(x)===tagKey(t)))return;+ out.push({i,tag:t}); });+ return out;+}+function tagsFromPrefixes(){+ const c=prefixCandidates(); if(!c.length)return 0;+ const by=new Map(c.map(x=>[x.i,x.tag]));+ return editTags((p,i)=>by.has(i)?[...(p.tags||[]),by.get(i)]:null);+}+function secTagsHtml(p){+ const t=p.tags||[], full=t.length>=TAG_MAX_COUNT;+ return `<div class="sec-tags">`+t.map(x=>`<span class="sec-tag">${esc(x)}<button type="button" class="sec-tag-x" data-tag="${esc(x)}" title="Remove the tag ${esc(x)} from this section" aria-label="Remove the tag ${esc(x)}">×</button></span>`).join('')++ `<input type="text" class="sec-tag-in" list="tagList" maxlength="${TAG_MAX_LEN}" autocomplete="off" placeholder="${full?'12 tags, the most a section can carry':t.length?'+ tag':'+ add a tag'}" aria-label="Add a tag to ${esc(p.name)}"${full?' disabled':''}></div>`;+}+// Type, then Enter or a comma. A pasted "a, b" adds both. The suggestions are every tag+// already in the plan (the datalist), so the same word is not spelled three ways.+function wireSecTags(row){+ const k=row.dataset.k, inp=row.querySelector('.sec-tag-in');+ row.querySelectorAll('.sec-tag-x').forEach(b=>b.addEventListener('click',()=>{ removePhaseTag(k,b.dataset.tag); }));+ if(!inp)return;+ const commit=()=>{+ const words=inp.value.split(',').map(w=>w.trim()).filter(Boolean);+ inp.value='';+ if(words.length)addPhaseTags(k,words);+ };+ inp.addEventListener('keydown',e=>{+ e.stopPropagation();+ if(e.key==='Enter'||e.key===','){ e.preventDefault(); commit(); }+ else if(e.key==='Escape'){ e.preventDefault(); if(inp.value)inp.value=''; else inp.blur(); }+ });+ // A comma that arrives by paste, or by a datalist pick followed by one, adds too.+ inp.addEventListener('input',()=>{ if(inp.value.includes(','))commit(); });+ // Clicking away commits what was typed, the way a table cell does (0.59.0 review);+ // Escape still discards it, because it empties the box first. Two blurs do NOT+ // commit: a repaint taking the box out of the page (the text is carried over to the+ // new box instead), and a press on one of this section's own chip x buttons, which+ // would otherwise repaint the row between its mousedown and its click and eat it.+ inp.addEventListener('blur',e=>{+ if(!inp.isConnected||!inp.value.trim())return;+ if(e.relatedTarget&&e.relatedTarget.closest&&e.relatedTarget.closest('.sec-tags')===inp.closest('.sec-tags'))return;+ // After the focus has landed, so the repaint this causes keeps it where it went+ // (another section's box, say) instead of losing it with the replaced node.+ setTimeout(()=>{ if(inp.isConnected&&inp.value.trim()&&document.activeElement!==inp)commit(); },0);+ });+}+function holdTagInput(list){+ const a=document.activeElement;+ const out=[];+ list.querySelectorAll('.sec-tag-in').forEach(inp=>{+ const focused=inp===a;+ if(!inp.value&&!focused)return;+ const row=inp.closest('.sec-row');+ out.push({k:row&&row.dataset.k,v:inp.value,focused,sel:focused?[inp.selectionStart,inp.selectionEnd]:null});+ });+ return out;+}+function restoreTagInput(list,held){+ (held||[]).forEach(h=>{+ const row=h.k&&list.querySelector(`.sec-row[data-k="${h.k}"]`); if(!row)return;+ const inp=row.querySelector('.sec-tag-in'); if(!inp||inp.disabled)return;+ inp.value=h.v;+ if(h.focused&&!list.querySelector('.sec-row.sec-editing')){ inp.focus(); try{ inp.setSelectionRange(h.sel[0],h.sel[1]); }catch(_){} }+ });+}+// The Tags card: every tag in the plan, how many sections carry it, and the two plan-wide+// edits, rename and remove. The name-prefix button sits on top when it has work to do.+let tagEditing=null; // the key of the tag whose rename box is open+function renderTagsCard(){+ const card=$('suTags'); if(!card)return;+ const tags=planTags();+ const dl=$('tagList'); if(dl)dl.innerHTML=tags.map(e=>`<option value="${esc(e.tag)}">`).join('');+ $('tagTotal').textContent=plural(tags.length,'tag');+ const pre=$('tagPrefix'), cand=prefixCandidates();+ if(cand.length){+ const names=[...new Map(cand.map(c=>[tagKey(c.tag),c.tag])).values()];+ pre.style.display='';+ pre.innerHTML=`<span class="tp-say">${plural(cand.length,'section')} ${cand.length===1?'is':'are'} named with a prefix, like "${esc(phases[cand[0].i].name)}".</span>`++ `<button type="button" class="de-btn primary" id="tagPrefixBtn" title="Adds ${esc(names.join(', '))} as tags to the sections whose names start with them. Adds tags only; no section is renamed. One Ctrl+Z takes it back.">Tag them: ${esc(names.slice(0,6).join(', '))}${names.length>6?', ...':''}</button>`;+ pre.querySelector('#tagPrefixBtn').addEventListener('click',()=>tagsFromPrefixes());+ } else { pre.style.display='none'; pre.innerHTML=''; }+ const list=card.querySelector('.tag-list');+ const typing=tagEditing&&list.querySelector('.tag-nm');+ const draft=typing?typing.value:null;+ if(tagEditing&&!tags.some(e=>e.key===tagEditing))tagEditing=null;+ list.innerHTML=tags.length?'':`<div class="tag-empty">No tags yet. Type one into the box under any section below${cand.length?', or tag them all from their name prefixes with the button above':''}.</div>`;+ tags.forEach(e=>{+ const row=document.createElement('div'); row.className='tag-row'; row.dataset.tag=e.key; row.setAttribute('role','listitem');+ const secs=e.idx.map(i=>phases[i].name);+ row.innerHTML=`<span class="tag-name"><span class="sec-tag">${esc(e.tag)}</span></span>`++ `<span class="tag-n" title="${esc(secs.join(', '))}">${plural(e.n,'section')}</span>`++ `<span class="tag-acts"><button type="button" class="tag-btn" data-a="rename" title="Rename this tag on every section that carries it">Rename</button>`++ `<button type="button" class="tag-btn danger" data-a="del" title="Remove this tag from all ${plural(e.n,'section')}. One Ctrl+Z puts it back.">Remove</button></span>`;+ list.appendChild(row);+ row.querySelector('[data-a="del"]').addEventListener('click',()=>{ tagEditing=null; removeTagEverywhere(e.tag); });+ row.querySelector('[data-a="rename"]').addEventListener('click',()=>{ tagEditing=e.key; renderTagsCard(); if(tagEditing){ const i=list.querySelector('.tag-nm'); if(i){ i.focus(); i.select(); } } });+ if(tagEditing===e.key){+ row.classList.add('tag-editing');+ const w=document.createElement('div'); w.className='tag-ren';+ w.innerHTML=`<input type="text" class="tag-nm" maxlength="${TAG_MAX_LEN}" value="${esc(draft!==null?draft:e.tag)}" aria-label="New name for the tag ${esc(e.tag)}">`++ `<button type="button" class="de-btn primary tag-ok">Rename</button><button type="button" class="de-btn secondary tag-no">Cancel</button>`;+ row.appendChild(w);+ const nm=w.querySelector('.tag-nm');+ const go=()=>{ const v=nm.value.trim(); tagEditing=null; if(v&&v!==e.tag)renameTagEverywhere(e.tag,v); else renderTagsCard(); };+ w.querySelector('.tag-ok').addEventListener('click',go);+ w.querySelector('.tag-no').addEventListener('click',()=>{ tagEditing=null; renderTagsCard(); });+ nm.addEventListener('keydown',ev=>{ ev.stopPropagation();+ if(ev.key==='Enter'){ ev.preventDefault(); go(); }+ else if(ev.key==='Escape'){ ev.preventDefault(); tagEditing=null; renderTagsCard(); } });+ if(typing){ nm.focus(); }+ }+ });+} // Where a section is NOW. Every row editor resolves its section through this at the // moment it acts, never through the index it was opened with. function phaseIndexOfKey(k){ return phases.findIndex(q=>phaseKey(q)===k); }@@ -7246,15 +7739,17 @@ $('btnExport').addEventListener('click',e=>{ // sidecar. `getState()` is a delta for the server to merge; this is the whole // merged plan, which is what an export is. function clientMergedData(){- const FIELDS=['type','uid','id','name','label','start','end','date','phase','owner','status','description','deps','note','striped','provenance'];+ const FIELDS=['type','uid','id','name','label','start','end','date','phase','owner','status','description','deps','note','striped','provenance','tags']; const clean=r=>{const o={};FIELDS.forEach(k=>{if(r[k]!==undefined)o[k]=r[k];}); if(o.status)o.status=statusKey(o.status); // the canonical vocabulary, as the server normalises on the way out+ tidyTags(o); // the data-file rule: a non-empty list or no key return o;}; // Sections travel in `phases`, which now carries name, lead and colour. Group // ROWS are gone from the export shape: they were never data, and a reader that // wants the section a row belongs to has always used `phase`. const phs=phases.map(p=>{const o={name:p.name,start:p.start,end:p.end};- if(p.uid)o.uid=p.uid; if(p.lead)o.lead=p.lead; if(p.color)o.color=p.color; return o;});+ if(p.uid)o.uid=p.uid; if(p.lead)o.lead=p.lead; if(p.color)o.color=p.color;+ if(p.tags&&p.tags.length)o.tags=p.tags.slice(); return o;}); return {project:Object.assign({},project),phases:phs, items:items.filter(r=>!isDerivedGroup(r)).map(clean)}; }@@ -7264,7 +7759,7 @@ function clientMergedData(){ const CSV_FORMULA_LEAD=/^[=+\-@\t\r]/; function csvGuardCell(s){return CSV_FORMULA_LEAD.test(s)?"'"+s:s;} function clientItemsToCSV(itemsArr,phasesArr){- const headers=['uid','id','name','start','end','phase','owner','status','description','provenance','provenance_url','deps','type','label','date','striped'];+ const headers=['uid','id','name','start','end','phase','owner','status','description','provenance','provenance_url','deps','tags','type','label','date','striped']; const cell=v=>{if(v==null)return '';const s=csvGuardCell(String(v)); return (s.includes(',')||s.includes('"')||s.includes('\n')||s.includes('\r'))?'"'+s.replace(/"/g,'""')+'"':s;}; const phaseName=i=>{const p=phasesArr&&phasesArr[i];return p&&p.name?p.name:(i==null?'':i);};@@ -7272,6 +7767,7 @@ function clientItemsToCSV(itemsArr,phasesArr){ itemsArr.forEach(item=>{ lines.push(headers.map(h=>{ if(h==='deps')return cell((item.deps||[]).join(';'));+ if(h==='tags')return cell((normalizeTags(item.tags)||[]).join(';')); if(h==='phase')return cell(phaseName(item.phase)); if(h==='status')return cell(statusKey(item.status)); if(h==='provenance'){const p=item.provenance;return cell(typeof p==='string'?p:(p&&p.label)||'');}@@ -9043,12 +9539,13 @@ function tableRows(){ // rather than the per-row _collapsed flag, so it is right even before a reflow. if(r.phase!==undefined&&isCollapsed(r.phase))return; if(r.phase!==undefined&&!phaseVis[r.phase])return;+ if(r.phase!==undefined&&!phaseShown(r.phase))return; if(isMs(r)&&!msVis)return; const o=ownerOf(r); if(own===UNASSIGNED){ if(o)return; } else if(own&&o!==own) return; if(st&&statusOf(r)!==st)return; if(q){- const hay=`${r.name||''} ${o} ${phaseNameOf(r)} ${r.description||''} ${r.status||''} ${deadlineOf(r)} ${fmt(deadlineOf(r))} ${provLabel(r)}`.toLowerCase();+ const hay=`${r.name||''} ${o} ${phaseNameOf(r)} ${phaseTagsOf(r).join(' ')} ${r.description||''} ${r.status||''} ${deadlineOf(r)} ${fmt(deadlineOf(r))} ${provLabel(r)}`.toLowerCase(); if(hay.indexOf(q)<0)return; } rows.push(r);@@ -9125,7 +9622,9 @@ function renderTable(){ 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.';+ const why=allLanesCollapsed()?'Every section is collapsed. Expand all, at the start of the legend, to see the rows.'+ :tagFilterOn()&&!phases.some((p,i)=>phaseShown(i)&&laneRows(i).length)?'No section with these tags has any rows. Pick other tags in the legend, or clear them.'+ :'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;@@ -9137,7 +9636,7 @@ function renderTable(){ return `<tr data-key="${esc(rowKey(r))}"${trCls?` class="${trCls}"`:''}>` +`<td class="c-name${ed}" data-field="name"><span class="trunc" data-title="${esc(r.name)}" data-dates="${esc(dates)}" data-body="${esc(r.description||'')}">` +`${ms?'<span class="ms-mark">◆</span>':''}${esc(r.name)}</span></td>`- +`<td class="c-phase${ed}" data-field="phase">${dot}${esc(phaseNameOf(r))}</td>`+ +`<td class="c-phase${ed}" data-field="phase"${phaseTagsOf(r).length?` title="Tags: ${esc(phaseTagsOf(r).join(', '))}"`:''}>${dot}${esc(phaseNameOf(r))}</td>` +`<td class="c-owner${ed}${o?'':' empty'}" data-field="owner">${o?esc(o):'unassigned'}</td>` +(ms?'<td class="c-date empty"></td>' :`<td class="c-date${ed}${sd?'':' empty'}" data-field="start">${sd?esc(fmt(sd)):'not set'}</td>`)@@ -10555,6 +11054,11 @@ items.forEach(r=>{ if(r.id)refreshLabelRow(r); else if(r.type==='milestone')refr // The chevron counts were written during the build, before the sidecar was merged, // so they reported the data file's lanes and not the plan's. Re-read them now that // the created rows, the deletions and the phase overrides are all in.+// The tags are the plan's from here on (the sidecar can add or clear them), so the+// saved tag filter is checked against them now, and the legend is rebuilt with them.+tagsReady=true;+pruneTagFilter();+rebuildLegend(); applyCollapse(); // Re-baseline AFTER restoring the sidecar, so replaying saved edits on load is // not mistaken for a fresh round of user changes.@@ -10633,6 +11137,7 @@ async function init(){ clampFontScalePref(); clampMsStylePref(); clampCritModePref();+ clampTagFilterPref(); applyStartupPrefs(); wireLaneToggle(); initTimeRange();@@ -10641,6 +11146,7 @@ async function init(){ captureBaselineSnapshot(); wireSaveFlush(); buildLegendCollapseAll();+ wireLegendTags(); buildLegendPhases(); buildLegendMilestone(); buildLegendDeps();
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.58.0",+ "version": "0.59.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.58.0",+ "version": "0.59.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": {
server.js+8−1@@ -678,7 +678,14 @@ function describeEvent(e) { : `section ${e.name ? `"${e.name}"` : (typeof e.phase === 'number' ? '#' + (e.phase + 1) : '?')}`; const was = f.length ? f.join(', ') : 'none'; if (!t.length) return `${who} cleared the tags on ${target} (was ${was})${via}`;- return `${who} tagged ${target} with ${t.join(', ')} (was ${was})${via}`;+ // Worded by direction (0.59.0 review): a removal written as "tagged X with <what is+ // left>" read like an addition. Compared without regard to case, as tags are.+ const k = x => x.toLowerCase();+ const added = t.filter(x => !f.some(y => k(y) === k(x))), removed = f.filter(x => !t.some(y => k(y) === k(x)));+ if (!removed.length && added.length) return `${who} tagged ${target} with ${t.join(', ')} (was ${was})${via}`;+ if (!added.length && removed.length) return `${who} untagged ${target}: ${removed.join(', ')} (now ${t.join(', ')})${via}`;+ if (added.length) return `${who} retagged ${target}: ${[...added.map(x => '+' + x), ...removed.map(x => '-' + x)].join(', ')} (now ${t.join(', ')})${via}`;+ return `${who} retagged ${target} as ${t.join(', ')} (was ${was})${via}`; } if (action === 'rename') { return `${who} renamed "${fld('from', 'name', '?')}" to "${fld('to', 'name', '?')}"${via}`;
test/server_test.mjs+9−1@@ -162,8 +162,12 @@ async function main() { { action: 'phase.tags', phase: 0, name: 'Tech · Fab', from: { tags: [] }, to: { tags: ['Tech'] }, via: 'setup' }, { action: 'phase.tags', phase: 1, from: 'junk', to: { tags: 'Tech' } }, { action: 'tags', to: { tags: [1, { a: 2 }, 'ok\nline'] }, extra: { weird: true } },+ // Worded by direction (0.59.0 review): a removal, a swap, and a change of case only.+ { action: 'phase.tags', phase: 0, name: 'Tech · Fab', from: { tags: ['Tech', 'long-lead'] }, to: { tags: ['Tech'] }, via: 'setup' },+ { action: 'phase.tags', phase: 0, name: 'Tech · Fab', from: { tags: ['Tech', 'Launch'] }, to: { tags: ['Tech', 'Go'] }, via: 'setup' },+ { action: 'phase.tags', phase: 0, name: 'Tech · Fab', from: { tags: ['tech'] }, to: { tags: ['Tech'] }, via: 'setup' }, ] }, { Authorization: 'Bearer ' + A.token, 'X-Ignore': '1' });- ok(lg.status === 200 && lg.j.added === 6, '/log accepts tags and phase.tags with the agent token', lg);+ ok(lg.status === 200 && lg.j.added === 9, '/log accepts tags and phase.tags with the agent token', lg); const mdRes = await fetch(A.base + '/changelog?format=md'); const md = await mdRes.text(); ok(mdRes.status === 200, 'changelog md renders 200 with malformed tag events');@@ -174,7 +178,11 @@ async function main() { 'adom-gantt agent tagged section "Tech · Fab" with Tech (was none) (setup)', 'adom-gantt agent cleared the tags on section #2 (was none)', 'adom-gantt agent tagged an item with ok line (was none)',+ 'adom-gantt agent untagged section "Tech · Fab": long-lead (now Tech) (setup)',+ 'adom-gantt agent retagged section "Tech · Fab": +Go, -Launch (now Tech, Go) (setup)',+ 'adom-gantt agent retagged section "Tech · Fab" as Tech (was tech) (setup)', ];+ ok(!/tagged section "Tech · Fab" with Tech \(was Tech, long-lead\)/.test(md), 'a removal no longer reads like an addition'); need.forEach(n => ok(md.includes(n), 'md line: ' + n, md.split('\n').filter(l => /tag/.test(l)))); const health = await getJ(A, '/health'); ok(!health.integrity || health.integrity.ok !== false, 'hash chain still whole after the writes', health.integrity);
test/ui_test.mjs+341−12@@ -6,6 +6,8 @@ // node test/ui_test.mjs --only <s> run only the flows whose name contains <s> (comma-separated // list allowed); the whole-suite gates (coverage by-registry, // untested count) are reported only on a full run+// UI_TEST_PORTS=8960-8979 the only ports the suite will listen on (the default); it fails+// loudly if none in the range is free // 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)@@ -79,13 +81,17 @@ const sleep = ms => new Promise(r => setTimeout(r, ms)); 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' } = {}) {+// `tags`: the first fifteen sections carry their group as a tag (Launch, Tech, Process) and+// the five Ops sections carry none, so the legend has tag chips AND the Setup page offers+// to tag the rest from their name prefixes. The coverage gate walks this one.+function seedPlan({ appName = 'launch-demo', title = 'Launch Demo: Jan 11, 2027', tags = false } = {}) { 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' });+ phases.push({ name: `${groups[Math.floor(p / 5)]} · Workstream ${String.fromCharCode(65 + p)} long name`, start: '2026-09', end: '2027-01',+ ...(tags && p < 15 ? { tags: [groups[Math.floor(p / 5)]] } : {}) }); // 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);@@ -173,17 +179,25 @@ async function expectReadouts(page, today, what) { // ── Server ── const servers = [];-let nextPort = 8960;+// The suite's ports are a FIXED, bounded range, so parallel agents and other tools can be+// given ports outside it safely: 8960-8979 by default (a full run starts about fifteen+// servers), or UI_TEST_PORTS=<lo>-<hi>. Until 0.59.0's review the probe walked upward from+// 8960 with no real bound, and a reviewer's own server on 8972 was driven by a run.+// Each call scans the whole range for a port this run has not handed out and nothing is+// listening on, and it fails loudly when there is none rather than wandering outside.+const PORT_RANGE = (() => { const m = String(process.env.UI_TEST_PORTS || '8960-8979').match(/^(\d+)-(\d+)$/);+ if (!m || +m[1] > +m[2]) throw new Error(`UI_TEST_PORTS must look like 8960-8979, got "${process.env.UI_TEST_PORTS}"`); return [+m[1], +m[2]]; })();+const portsGiven = new Set(); // 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++;+ for (let p = PORT_RANGE[0]; p <= PORT_RANGE[1]; p++) {+ if (portsGiven.has(p)) continue; 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');+ if (ok) { portsGiven.add(p); return p; } }+ throw new Error(`no free scratch port in ${PORT_RANGE[0]}..${PORT_RANGE[1]} (${portsGiven.size} used by this run; set UI_TEST_PORTS=<lo>-<hi> for another range)`); } async function startServer(plan, name) { const dir = mkdtempSync(join(TMP, name + '-'));@@ -481,8 +495,11 @@ async function main() { // this row is; a person waits for it before reaching for the row, and so does this. // The toast pauses while the pointer is over it (by design), and the pointer is still // where the last click left it, so it is moved off first.+ // The "saved" pill sits at the bottom right, over this row's delete button, so it+ // is waited out too. Until 0.59.0 the toast stuck until the pointer moved (a bug),+ // and that wait happened to outlast the pill. await page.mouse.move(700, 120);- await page.waitForFunction(() => !document.querySelector('#undoToast.show'), { timeout: 8000 });+ await page.waitForFunction(() => !document.querySelector('#undoToast.show') && !document.querySelector('#saveInd.show'), { timeout: 8000 }); await page.hover(`tr[data-key="${keyOfFinale}"]`); await settle(page, 200); // A live repaint can swap the row under a still pointer; a real hand is never // perfectly still, so nudge the pointer inside the row, as it would.@@ -812,6 +829,7 @@ async function main() { if (wantAny('editor', 'new item fields', 'table filters')) await editorFlows(browser, S); if (wantAny('export menu', 'import menu')) await menuFlows(browser, S); if (wantAny('sections')) await sectionsFlow(browser);+ if (wantAny('tags')) await tagsFlow(browser); if (wantAny('viewport')) { // ── Viewport matrix ──@@ -869,7 +887,10 @@ async function main() { if (wantAny('coverage')) { // ── Coverage gate ── section = 'coverage';- await coverageGate(browser, S.url);+ // A plan WITH tags (0.59.0), so the legend's tag chips, the section fold, the Setup+ // tag editors, the Tags card and the prefix button are all on screen to be walked.+ const ST = await startServer(seedPlan({ tags: true }), 'plan20-tags');+ await coverageGate(browser, ST.url); } if (wantAny('leak')) {@@ -1075,6 +1096,19 @@ const MANIFEST = [ { sel: '.sec-dest', label: 'section remove: destination', terminal: true, by: 'sections' }, { sel: '.sec-go', label: 'section remove: confirm', terminal: true, by: 'sections' }, { sel: '.sec-no', label: 'section remove: Cancel', safe: true },+ // Tags (0.59.0). The legend's tag chips are a filter, a view setting like the section+ // chips, so they are safe and clicked twice; the Setup page's tag edits change the plan+ // and are proven by the `tags` flow.+ { sel: '.tag-chip', label: 'legend tag chip (filter)', safe: true, twice: true, key: c => c.ds.tag, pin: ['launch', 'tech', 'process'] },+ { sel: '#legendSecs', label: 'legend: fold or unfold the section chips', safe: true, twice: true },+ { sel: '#tagClear', label: 'legend: clear the tag filter', safe: true },+ { sel: '.sec-tag-in', label: 'section tag: type to add', terminal: true, by: 'tags' },+ { sel: '.sec-tag-x', label: 'section tag: remove one', terminal: true, by: 'tags', key: c => c.ds.tag, pin: ['Launch', 'Tech', 'Process'] },+ { sel: '#tagPrefixBtn', label: 'tags from name prefixes', terminal: true, by: 'tags' },+ { sel: '.tag-btn', label: 'Tags card: rename / remove everywhere', terminal: true, by: 'tags', key: c => c.ds.a, pin: ['rename', 'del'] },+ { sel: '.tag-nm', label: 'Tags card: new name', terminal: true, by: 'tags' },+ { sel: '.tag-ok', label: 'Tags card: rename everywhere', terminal: true, by: 'tags' },+ { sel: '.tag-no', label: 'Tags card: cancel a rename', safe: true }, { sel: '#ncType', label: 'new: type', terminal: true, by: 'new item fields' }, { sel: '#ncPhase', label: 'new: section', terminal: true, by: 'new item fields' }, { sel: '#ncName', label: 'new: name', terminal: true, by: 'live: create' },@@ -1193,8 +1227,16 @@ async function clickBar(page, id, mods = []) { await settle(page, 150); } async function clickMilestone(page, uid) {- await page.evaluate(u => document.querySelector(`.ms-hit[data-msuid="${u}"]`).scrollIntoView({ block: 'center', inline: 'center' }), uid); await settle(page, 100);- const q = await page.evaluate(u => { const r = document.querySelector(`.ms-hit[data-msuid="${u}"]`).getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }, uid);+ // The diamond's hit strip, or in line style (where the milestone's own row is hidden)+ // the cap on its dashed rule: whichever of the two has a box. The coverage walk cycles+ // the milestone style as it clicks, so either can be the one on screen.+ const target = `.ms-hit[data-msuid="${uid}"], .ms-cap[data-msuid="${uid}"]`;+ const pick = sel => page.evaluate(s => { const e = [...document.querySelectorAll(s)].find(x => { const r = x.getBoundingClientRect(); return r.width > 0 && r.height > 0; });+ if (!e) return null; e.scrollIntoView({ block: 'center', inline: 'center' }); return true; }, sel);+ if (!await pick(target)) throw new Error(`clickMilestone(${uid}): no hit strip or cap with a box`);+ await settle(page, 100);+ const q = await page.evaluate(s => { const e = [...document.querySelectorAll(s)].find(x => { const r = x.getBoundingClientRect(); return r.width > 0 && r.height > 0; });+ const r = e.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }, target); await page.mouse.click(q.x, q.y); await settle(page, 150); } // Hover a row label (the pencil is opacity 0 until then) and click the pencil.@@ -1221,6 +1263,10 @@ async function coverageGate(browser, url) { const page = await openPage(browser, url); const reset = async () => { await page.keyboard.press('Escape'); await page.keyboard.press('Escape');+ // The tag filter and the unfolded section chips are view settings a state turned on;+ // they are turned off again so the next state opens on the plain legend.+ await page.evaluate(() => { const c = document.getElementById('tagClear'); if (c && c.isConnected) c.click();+ const t = document.getElementById('legendSecs'); if (t && t.isConnected && t.classList.contains('open')) t.click(); }); // 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); await page.mouse.move(700, 5);@@ -1237,6 +1283,9 @@ async function coverageGate(browser, url) { ['setup: rename', async () => { await view(page, 'setup'); await page.click('.sec-row[data-i="1"] .sec-btn[data-a="edit"]'); }], ['setup: colour', async () => { await view(page, 'setup'); await page.click('.sec-row[data-i="1"] .sec-sw'); }], ['setup: remove', async () => { await view(page, 'setup'); await page.click('.sec-row[data-i="1"] .sec-btn[data-a="del"]'); }],+ ['setup: tag rename', async () => { await view(page, 'setup'); await page.click('#suTags .tag-row .tag-btn[data-a="rename"]'); }],+ ['legend: sections unfolded', async () => { await view(page, 'gantt'); await page.click('#legendSecs'); }],+ ['legend: tag filter on', async () => { await view(page, 'gantt'); await page.click('.tag-chip[data-tag="tech"]'); }], ['new item', async () => { await page.click('#btnNew'); }], ['bar editor', async () => { await view(page, 'gantt'); await clickBar(page, 'finale'); }], ['milestone editor', async () => { await view(page, 'gantt'); await clickMilestone(page, kick.uid); }],@@ -1270,7 +1319,7 @@ async function coverageGate(browser, url) { // A state that renders nothing new would make every check about it vacuous. } // The added states must really have opened what they claim to.- for (const [st, sel] of [['bar editor', '#deApply'], ['milestone editor', '#meApply'], ['item editor', '#imApply'], ['batch panel', '#bApply'], ['table row hover', '.row-del'], ['chart row hover', '.row-edit'], ['table sorted', '#toPlanOrder']])+ for (const [st, sel] of [['setup: tag rename', '.tag-ok'], ['legend: tag filter on', '#tagClear'], ['bar editor', '#deApply'], ['milestone editor', '#meApply'], ['item editor', '#imApply'], ['batch panel', '#bApply'], ['table row hover', '.row-del'], ['chart row hover', '.row-edit'], ['table sorted', '#toPlanOrder']]) check(`state "${st}" opened (its ${sel} rendered)`, seen.has(entryId(MANIFEST.find(d => d.sel === sel))), `controls seen in that state: ${stateControls[st]}`); await view(page, 'gantt'); check('every rendered control is declared (no orphans)', orphans.length === 0, [...new Set(orphans)].slice(0, 8).join(' | '));@@ -1638,6 +1687,7 @@ async function sectionsFlow(browser) { check('the header has no Sections button any more', !(await page.$('#btnSections'))); await view(page, 'setup');+ check('no "tags from name prefixes" button on a plan whose names have no " · "', !(await page.$('#tagPrefixBtn')) && await page.$eval('#tagPrefix', e => !window.__uiVis(e))); check('Setup is a page: the list is on screen and no modal is open', await page.$eval('#setupView', e => window.__uiVis(e)) && !(await page.$('.sec-back'))); check('Setup hides the chart and the legend, as Analytics does', await page.evaluate(() => !window.__uiVis(document.querySelector('.chart-wrap')) && !window.__uiVis(document.querySelector('.legend')))); const rowInfo = await page.$eval('.sec-row[data-i="0"]', r => ({ n: r.querySelector('.sec-count').textContent, d: r.querySelector('.sec-dates').textContent }));@@ -1821,6 +1871,285 @@ async function sectionsFlow(browser) { await yp.close(); } +// ── Tags on sections (0.59.0) ──+// Every edit is asserted against the plan the server holds (/export/json after the save),+// then undone and redone; the changelog, a reload, the legend filter in both lenses, the+// fold, the snapshot, and the widths are checked on the same plan.+async function tagsFlow(browser) {+ section = 'tags';+ const T = (id, name, ph) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph });+ const PH = ['Launch · Gates', 'Launch · Party', 'Tech · Fab', 'Tech · Oven', 'Ops · Supply', 'Misc'];+ // A narrow label column and a long aside on the first two sections, so the header chips+ // have to win their room from the aside (0.59.0 review: they were the thing cut off).+ const LONG = 'what launched means, the scorecard, the rehearsals, the party and everything after it (Drew)';+ const X = await startServer({ project: { title: 'Tags Test', appName: 'tags-test', view: { mode: 'gantt', grain: 'week', labelWidth: 300 } },+ // "Misc" arrives with a messy list, to be normalised on the way in exactly as the server does.+ phases: PH.map((name, i) => ({ name, start: '2026-09', end: '2026-12', ...(i < 2 ? { lead: LONG } : {}), ...(i === 5 ? { tags: [' misc ', 'MISC', 'x'.repeat(40), 5, ''] } : {}) })),+ items: [T('g1', 'Gates one', 0), T('g2', 'Gates two', 0), { type: 'milestone', name: 'Gate', date: '2026-11-02', phase: 0 },+ T('p1', 'Party one', 1), T('p2', 'Party two', 1), T('f1', 'Fab one', 2), T('f2', 'Fab two', 2),+ T('o1', 'Oven one', 3), T('o2', 'Oven two', 3), T('s1', 'Supply one', 4), T('s2', 'Supply two', 4),+ T('m1', 'Misc one', 5), T('m2', 'Misc two', 5)] }, 'tags');+ const ROWS = [3, 2, 2, 2, 2, 2]; // rows per section, in PH order+ let page = await openPage(browser, X.url);+ const X32 = 'x'.repeat(32);+ const tagsOf = pl => Object.fromEntries(pl.phases.map(p => [p.name, p.tags || []]));+ const expectTags = async (what, want) => {+ const got = tagsOf(await savedPlan(X.url));+ const bad = Object.keys(want).filter(k => JSON.stringify(got[k]) !== JSON.stringify(want[k])).map(k => `${k}: ${JSON.stringify(got[k])} (want ${JSON.stringify(want[k])})`);+ check(`${what}: saved tags`, bad.length === 0, bad.join('; '));+ // and the Setup page shows the same chips on every section+ const shown = await page.$$eval('#setupView .sec-row', rows => Object.fromEntries(rows.map(r => [r.querySelector('.sec-name').firstChild.textContent, [...r.querySelectorAll('.sec-tag')].map(c => c.firstChild.textContent)])));+ const bad2 = Object.keys(want).filter(k => JSON.stringify(shown[k]) !== JSON.stringify(want[k])).map(k => `${k}: ${JSON.stringify(shown[k])}`);+ check(`${what}: the Setup page shows the same`, bad2.length === 0, bad2.join('; '));+ };+ const rowSel = async name => `.sec-row[data-i="${(await page.$$eval('#setupView .sec-row .sec-name', e => e.map(x => x.firstChild.textContent))).indexOf(name)}"]`;+ const redo = async () => { 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, 250); };+ const blur = () => page.evaluate(() => document.activeElement && document.activeElement.blur());+ const log = async () => (await (await fetch(X.url + 'changelog')).json()).events.filter(e => e.action === 'phase.tags');+ const md = async () => (await fetch(X.url + 'changelog?format=md')).text();++ await view(page, 'setup');+ let W = Object.fromEntries(PH.map(n => [n, []])); W.Misc = ['misc', X32];+ await expectTags('on load, a messy data-file list is normalised (trim, case-blind de-dupe, 32 chars, non-strings dropped)', W);++ // ── The prefix button ──+ check('the prefix button is offered, naming the prefixes', /Launch, Tech, Ops/.test(await page.$eval('#tagPrefixBtn', b => b.textContent).catch(() => '')));+ const n0 = (await log()).length;+ await page.click('#tagPrefixBtn'); await settle(page, 300);+ W = { 'Launch · Gates': ['Launch'], 'Launch · Party': ['Launch'], 'Tech · Fab': ['Tech'], 'Tech · Oven': ['Tech'], 'Ops · Supply': ['Ops'], Misc: ['misc', X32] };+ await expectTags('the prefix button tags each section with its name prefix', W);+ check('it renamed nothing', JSON.stringify((await exportPlan(X.url)).phases.map(p => p.name)) === JSON.stringify(PH));+ check('the button is gone once there is nothing left to add', !(await page.$('#tagPrefixBtn')));+ let L = (await log()).slice(n0);+ check('the changelog has one phase.tags event per section, via setup', L.length === 5 && L.every(e => e.via === 'setup') && L[0].name === 'Launch · Gates' && L[0].phase === 0+ && JSON.stringify(L[0].from.tags) === '[]' && JSON.stringify(L[0].to.tags) === '["Launch"]', JSON.stringify(L.slice(0, 2)));+ check('in one batch (one save, consecutive revs)', L.length === 5 && L.every((e, i) => i === 0 || e.rev === L[i - 1].rev + 1));+ check('the markdown changelog reads it as a sentence', (await md()).includes('tagged section "Launch · Gates" with Launch (was none) (setup)'));+ await undo(page);+ await expectTags('ONE Ctrl+Z takes the whole prefix pass back', Object.fromEntries(PH.map(n => [n, n === 'Misc' ? ['misc', X32] : []])));+ check('and the button is offered again', !!(await page.$('#tagPrefixBtn')));+ await redo();+ await expectTags('redo puts it all back', W);+ L = (await log()).slice(n0);+ check('the undo and the redo are logged too', L.filter(e => e.via === 'undo').length === 5 && L.filter(e => e.via === 'redo').length === 5, JSON.stringify(L.map(e => e.via)));++ // ── Type to add, comma to add, x to remove ──+ let fab = await rowSel('Tech · Fab');+ await page.click(`${fab} .sec-tag-in`); await page.keyboard.type('long-lead'); await page.keyboard.press('Enter'); await settle(page, 200);+ W['Tech · Fab'] = ['Tech', 'long-lead'];+ check('after Enter the box is empty and still has the focus, on the same section', await page.evaluate(s => { const a = document.activeElement; return a && a.classList.contains('sec-tag-in') && a.value === '' && a.closest('.sec-row') === document.querySelector(s); }, fab));+ await page.keyboard.type('TECH'); await page.keyboard.press('Enter'); await settle(page, 150);+ await page.keyboard.type('alpha, beta,'); await settle(page, 200);+ W['Tech · Fab'] = ['Tech', 'long-lead', 'alpha', 'beta'];+ await expectTags('Enter adds a tag, a comma adds one, and a tag the section has (any case) is not added twice', W);+ await page.click(`${fab} .sec-tag-x[data-tag="alpha"]`); await settle(page, 200);+ W['Tech · Fab'] = ['Tech', 'long-lead', 'beta'];+ await expectTags('the x on a chip removes that tag', W);+ await blur();+ await undo(page);+ await expectTags('Ctrl+Z puts it back', { ...W, 'Tech · Fab': ['Tech', 'long-lead', 'alpha', 'beta'] });+ await redo();+ await expectTags('redo removes it again', W);+ // Clicking away commits what was typed (0.59.0 review); Escape discards it.+ let ovenR = await rowSel('Tech · Oven');+ await page.click(`${ovenR} .sec-tag-in`); await page.keyboard.type('blurtag');+ await page.click('#suSections h3'); await settle(page, 250);+ await expectTags('clicking away commits a typed tag', { ...W, 'Tech · Oven': ['Tech', 'blurtag'] });+ ovenR = await rowSel('Tech · Oven');+ await page.click(`${ovenR} .sec-tag-in`); await page.keyboard.type('nope'); await page.keyboard.press('Escape');+ await page.click('#suSections h3'); await settle(page, 250);+ await expectTags('Escape discards it instead', { ...W, 'Tech · Oven': ['Tech', 'blurtag'] });+ await page.click(`${ovenR} .sec-tag-x[data-tag="blurtag"]`); await settle(page, 250);+ await expectTags('(and the x takes it off again)', W);+ check('the Tags card sits above the section list, where the prefix button is seen', await page.evaluate(() => document.getElementById('suTags').getBoundingClientRect().top < document.getElementById('suSections').getBoundingClientRect().top));+ check('the suggestions are the plan\'s tags', JSON.stringify(await page.$$eval('#tagList option', o => o.map(x => x.value))) === JSON.stringify(['Launch', 'Tech', 'long-lead', 'beta', 'Ops', 'misc', X32]));++ // ── The Tags card: counts, rename everywhere, remove everywhere ──+ const card = () => page.$$eval('#suTags .tag-row', rows => rows.map(r => r.querySelector('.tag-name').textContent + ':' + parseInt(r.querySelector('.tag-n').textContent, 10)));+ check('the Tags card lists every tag with how many sections carry it', JSON.stringify(await card()) === JSON.stringify(['Launch:2', 'Tech:2', 'long-lead:1', 'beta:1', 'Ops:1', 'misc:1', X32 + ':1']), JSON.stringify(await card()));+ await page.click('#suTags .tag-row[data-tag="tech"] .tag-btn[data-a="rename"]'); await settle(page, 100);+ await page.$eval('#suTags .tag-nm', e => { e.value = 'Engineering'; });+ await page.click('#suTags .tag-ok'); await settle(page, 250);+ W['Tech · Fab'] = ['Engineering', 'long-lead', 'beta']; W['Tech · Oven'] = ['Engineering'];+ await expectTags('Rename changes the tag on every section, in place', W);+ await undo(page);+ await expectTags('ONE Ctrl+Z takes the rename back on both', { ...W, 'Tech · Fab': ['Tech', 'long-lead', 'beta'], 'Tech · Oven': ['Tech'] });+ await redo();+ await expectTags('redo renames both again', W);+ await page.click('#suTags .tag-row[data-tag="beta"] .tag-btn[data-a="del"]'); await settle(page, 250);+ W['Tech · Fab'] = ['Engineering', 'long-lead'];+ await expectTags('Remove takes the tag off every section', W);+ // A rename onto a tag a section already has merges rather than duplicating.+ await page.click('#suTags .tag-row[data-tag="long-lead"] .tag-btn[data-a="rename"]'); await settle(page, 100);+ await page.$eval('#suTags .tag-nm', e => { e.value = 'engineering'; }); await page.focus('#suTags .tag-nm'); await page.keyboard.press('Enter'); await settle(page, 250);+ W['Tech · Fab'] = ['Engineering'];+ await expectTags('a rename onto a tag the section already has merges into it', W);+ await blur(); await undo(page);+ W['Tech · Fab'] = ['Engineering', 'long-lead'];+ await expectTags('and one Ctrl+Z splits it again', W);+ // Escape closes a rename box whatever holds the focus.+ await page.click('#suTags .tag-row[data-tag="ops"] .tag-btn[data-a="rename"]'); await settle(page, 100);+ await blur(); await page.keyboard.press('Escape'); await settle(page, 100);+ check('Escape with nothing focused cancels a tag rename', !(await page.$('#suTags .tag-nm')));++ // ── Coordinator item: Escape closes an open row editor, WHATEVER has the focus ──+ for (const [what, a, focus] of [['in another section\'s tag box', 'edit', async () => page.focus(`${await rowSel('Misc')} .sec-tag-in`)],+ ['in the new-section box', 'edit', async () => page.focus('#secNew')], ['nowhere', 'del', blur], ['on the swatch', 'sw', async () => page.focus(`${await rowSel('Tech · Oven')} .sec-sw`)]]) {+ const r = await rowSel('Tech · Oven');+ await page.click(a === 'sw' ? `${r} .sec-sw` : `${r} .sec-btn[data-a="${a}"]`); await settle(page, 100);+ await focus(); await page.keyboard.press('Escape'); await settle(page, 150);+ check(`Escape with the focus ${what} closes the open row editor (${a})`, !(await page.$('.sec-row.sec-editing')));+ }++ // ── Persistence ──+ await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await expectTags('after a reload', W);+ const savedEvents = (await log()).length;++ // ── The chart: header chips, the legend, the filter ──+ await view(page, 'gantt');+ const hdrTags = await page.$$eval('.label-row.group', g => g.map(x => [...x.querySelectorAll('.lane-tag')].map(t => t.textContent).join('+')));+ check('each section header shows its tags as chips', JSON.stringify(hdrTags) === JSON.stringify(['Launch', 'Launch', 'Engineering+long-lead', 'Engineering', 'Ops', 'misc+' + X32]), JSON.stringify(hdrTags));+ const clip = await page.evaluate(() => { const col = document.getElementById('labels').getBoundingClientRect();+ return [...document.querySelectorAll('.label-row.group')].flatMap(g => { const nm = g.querySelector('.lane-name').getBoundingClientRect();+ return [...g.querySelectorAll('.lane-tag')].map(t => ({ t: t.textContent, r: t.getBoundingClientRect() })).filter(x => x.r.right > Math.min(nm.right, col.right) + 0.5 || x.r.left < col.left || x.r.width < 8)+ .map(x => `${g.querySelector('.lane-title').textContent}: ${x.t.slice(0, 12)} @${Math.round(x.r.left)}..${Math.round(x.r.right)} (name ends ${Math.round(nm.right)})`); }); });+ check('every header\'s tag chips are whole and inside the label column, even after a long aside', clip.length === 0, clip.join(' | '));+ check('the long aside gave way instead (it is cut)', await page.evaluate(() => { const l = document.querySelector('.label-row.group .lane-lead'); return !!l && l.scrollWidth > l.clientWidth; }));+ check('the header chips carry no section colour', await page.evaluate(() => { const t = document.querySelector('.lane-tag'); const cs = getComputedStyle(t); return cs.backgroundColor === 'rgba(0, 0, 0, 0)' || cs.backgroundColor === 'transparent'; }));+ const vis = () => page.evaluate(() => ({+ rows: [...document.querySelectorAll('.label-row:not(.group)')].filter(e => e.checkVisibility()).length,+ heads: [...document.querySelectorAll('.label-row.group')].filter(e => e.checkVisibility()).map(e => e.querySelector('.lane-name').firstChild.textContent),+ chips: [...document.querySelectorAll('#legend .phase-chip')].map(c => c.textContent.trim()),+ tagChips: [...document.querySelectorAll('#legend .tag-chip')].map(c => c.dataset.tag + (c.classList.contains('on') ? '*' : '')),+ fold: (e => e && e.isConnected ? e.textContent.trim() : null)(document.getElementById('legendSecs')),+ clear: !!document.getElementById('tagClear')?.isConnected,+ }));+ const tableCount = async () => { await view(page, 'table'); const n = await page.$$eval('#tableView tbody tr[data-key]', e => e.length); await view(page, 'gantt'); return n; };+ let v = await vis();+ check('the legend leads with a chip per tag, in plan order', JSON.stringify(v.tagChips) === JSON.stringify(['launch', 'engineering', 'long-lead', 'ops', 'misc', X32.toLowerCase()]), JSON.stringify(v.tagChips));+ check('with tags in the plan the section chips fold behind one "6 sections" chip', v.chips.length === 0 && v.fold === '6 sections', JSON.stringify(v));+ // The chart shows 12 label rows for 13 items: in line style a milestone's own grid row is+ // hidden, its dashed rule carries it. The table lists all 13.+ check('with no tag chosen every section and row is shown (12 chart rows, 13 table rows)', v.rows === 12 && v.heads.length === 6 && await tableCount() === 13, JSON.stringify(v));+ await page.click('#legendSecs'); await settle(page, 150);+ v = await vis();+ check('the fold chip opens all six section chips', v.chips.length === 6 && v.fold === 'Fewer');+ await page.click('#legendSecs'); await settle(page, 150);+ await page.click('.tag-chip[data-tag="engineering"]'); await settle(page, 200);+ v = await vis();+ check('choosing a tag shows only its sections in the chart (4 rows, 2 headers)', v.rows === 4 && JSON.stringify(v.heads) === JSON.stringify(['Tech · Fab', 'Tech · Oven']), JSON.stringify(v));+ check('and only their section chips come back, in front of the rest of the legend', JSON.stringify(v.chips) === JSON.stringify(['Tech · Fab', 'Tech · Oven']) && v.fold === null && v.clear, JSON.stringify(v));+ check('and the table agrees (4 rows)', await tableCount() === 4);+ await page.click('.tag-chip[data-tag="launch"]'); await settle(page, 200);+ v = await vis();+ check('a second tag shows the sections carrying EITHER (8 chart rows, 9 table rows)', v.rows === 8 && v.heads.length === 4 && await tableCount() === 9, JSON.stringify(v));+ check('behind a chosen tag that is its prefix, a section chip drops the repeated prefix', JSON.stringify(v.chips) === JSON.stringify(['Gates', 'Party', 'Tech · Fab', 'Tech · Oven']), JSON.stringify(v.chips));+ // composes with a section chip ...+ await page.click('#legend .phase-chip'); await settle(page, 200);+ const dimmed = await page.$$eval('.label-row.dim:not(.group)', e => e.filter(x => x.checkVisibility()).length);+ check('a section chip still switches its section off inside the filter (2 chart rows dim, 6 left in the table)', dimmed === 2 && await tableCount() === 6, `${dimmed}`);+ await page.click('#legend .phase-chip'); await settle(page, 200);+ // ... and with Collapse all+ await page.click('#btnCollapseAll'); await settle(page, 200);+ v = await vis();+ check('Collapse all folds the filtered sections to their headers', v.rows === 0 && v.heads.length === 4 && await page.$eval('#btnCollapseAll', b => b.textContent.trim()) === 'Expand all', JSON.stringify(v));+ await page.click('#btnCollapseAll'); await settle(page, 200);+ check('Expand all brings back the filtered rows only', (await vis()).rows === 8);+ await page.click('#btnCollapseAll'); await settle(page, 200);+ await page.click('.tag-chip[data-tag="launch"]'); await settle(page, 200);+ check('a filtered Collapse all folded only the sections it showed', await page.evaluate(() => [...document.querySelectorAll('.lane-toggle.collapsed')].filter(e => e.checkVisibility()).length) === 2+ && (await vis()).rows === 0);+ await page.click('#btnCollapseAll'); await settle(page, 200); // Expand all: the two Tech lanes+ await page.click('.tag-chip[data-tag="launch"]'); await settle(page, 200);+ check('and a filtered Expand all opened only the sections it showed (Launch still folded)', (await vis()).rows === 4, JSON.stringify(await vis()));+ await page.click('#btnCollapseAll'); await settle(page, 200); // mixed, so Collapse all+ await page.click('#btnCollapseAll'); await settle(page, 200); // then Expand all+ check('Collapse all then Expand all opens every shown section', (await vis()).rows === 8, JSON.stringify(await vis()));+ const flt = async () => page.evaluate(() => JSON.parse(localStorage.getItem('adom-gantt:prefs') || '{}').tagFilter);+ check('the filter is a view pref, not the plan', JSON.stringify(await flt()) === '["Engineering","Launch"]' && (await log()).length === savedEvents+ && JSON.stringify(tagsOf(await exportPlan(X.url))) === JSON.stringify(W), JSON.stringify(await flt()));+ await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ v = await vis();+ check('the filter survives a reload', JSON.stringify(v.tagChips.filter(t => t.endsWith('*'))) === '["launch*","engineering*"]' && v.rows === 8 && await tableCount() === 9, JSON.stringify(v));+ await shot(page, 'tags-1440-filtered');+ await assertNoSidewaysScroll(page, '1440, legend with tag chips and a filter');++ // The filter follows a rename, and the rename's Ctrl+Z takes the filter back with it.+ await view(page, 'setup');+ await page.click('#suTags .tag-row[data-tag="launch"] .tag-btn[data-a="rename"]'); await settle(page, 100);+ await page.$eval('#suTags .tag-nm', e => { e.value = 'Go'; });+ await page.click('#suTags .tag-ok'); await settle(page, 250);+ await view(page, 'gantt');+ v = await vis();+ check('renaming a chosen tag keeps it chosen under its new name', JSON.stringify(await flt()) === '["Engineering","Go"]' && v.tagChips.includes('go*') && v.rows === 8, JSON.stringify({ f: await flt(), v }));+ await undo(page);+ v = await vis();+ check('Ctrl+Z of the rename puts the filter back on the old name', JSON.stringify(await flt()) === '["Engineering","Launch"]' && v.tagChips.includes('launch*') && v.rows === 8, JSON.stringify({ f: await flt(), v }));+ await redo();+ check('redo moves it on again', JSON.stringify(await flt()) === '["Engineering","Go"]' && (await vis()).rows === 8, JSON.stringify(await flt()));+ await undo(page);+ check('(undone, for what follows)', JSON.stringify(await flt()) === '["Engineering","Launch"]' && JSON.stringify(tagsOf(await savedPlan(X.url))) === JSON.stringify(W));++ // A tag that stops existing leaves the filter.+ await view(page, 'setup');+ await page.click('#suTags .tag-row[data-tag="launch"] .tag-btn[data-a="del"]'); await settle(page, 250);+ W['Launch · Gates'] = []; W['Launch · Party'] = [];+ await expectTags('Remove everywhere', W);+ await view(page, 'gantt');+ v = await vis();+ check('a tag removed from the plan drops out of the filter', JSON.stringify(await flt()) === '["Engineering"]' && v.rows === 4 && !v.tagChips.some(t => t.startsWith('launch')), JSON.stringify(v));+ await page.click('#tagClear'); await settle(page, 200);+ v = await vis();+ check('Clear tags shows every section again', v.rows === 12 && JSON.stringify(await flt()) === '[]');+ // The toast (coordinator item): with the pointer resting where the toast appears, Ctrl+Z's+ // toast still goes away on its own.+ await page.mouse.move(720, 872);+ await undo(page);+ await page.waitForFunction(() => document.querySelector('#undoToast.show'), { timeout: 2000 }).catch(() => {});+ const gone = await page.waitForFunction(() => !document.querySelector('#undoToast.show'), { timeout: 6000 }).then(() => true, () => false);+ check('an undo toast under a pointer that never moves still goes away', gone);+ await page.mouse.move(700, 120);+ W['Launch · Gates'] = ['Launch']; W['Launch · Party'] = ['Launch'];+ await view(page, 'setup'); await expectTags('(that undo put Launch back)', W); await view(page, 'gantt');++ // ── Phone width: the legend with tag chips and a filter ──+ const m = await openPage(browser, X.url, { w: 393, h: 852, mobile: true });+ await assertNoSidewaysScroll(m, '393, legend with tag chips');+ await m.click('.tag-chip[data-tag="engineering"]'); await settle(m, 200);+ await assertNoSidewaysScroll(m, '393, legend with a tag chosen');+ check('393: the filter works there too', await m.evaluate(() => [...document.querySelectorAll('.label-row:not(.group)')].filter(e => e.checkVisibility()).length) === 4);+ await shot(m, 'tags-393-filtered');+ await m.evaluate(() => document.querySelector('.vt-btn[data-view="setup"]').click()); await settle(m, 200);+ // What is SEEN past the edge: an element cut off by an ancestor that clips (the aside+ // inside a section name's ellipsis) ends where that ancestor ends. A scroll container+ // does not count as a clip: scrolling sideways is exactly what this must catch.+ const over = await m.evaluate(() => [...document.querySelectorAll('#setupView *')].filter(e => window.__uiVis(e)).filter(e => {+ let right = e.getBoundingClientRect().right;+ for (let a = e.parentElement; a && a.id !== 'setupView'; a = a.parentElement) if (/^(hidden|clip)$/.test(getComputedStyle(a).overflowX)) right = Math.min(right, a.getBoundingClientRect().right);+ return right > innerWidth + 0.5; })+ .map(e => (e.className || e.tagName) + ' @' + Math.round(e.getBoundingClientRect().right)));+ check('393: nothing on the Setup page, tags included, runs past the window', over.length === 0, over.join(' | '));+ await assertNoSidewaysScroll(m, '393, setup with tags');+ await shot(m, 'tags-393-setup');+ await m.close();+ await page.close();++ // ── The snapshot: tags shown, filterable, and editable with no server ──+ const snapFile = join(TMP, 'tags-snapshot.html'); writeFileSync(snapFile, await (await fetch(X.url + 'export/html')).text());+ const sp = await openPage(browser, 'file://' + snapFile);+ check('the snapshot shows the section tags in the chart', (await sp.$$eval('.lane-tag', e => e.map(x => x.textContent))).includes('long-lead'));+ await sp.click('.tag-chip[data-tag="engineering"]'); await settle(sp, 200);+ check('the snapshot filters by tag', await sp.evaluate(() => [...document.querySelectorAll('.label-row:not(.group)')].filter(e => e.checkVisibility()).length) === 4);+ await view(sp, 'setup');+ const ms = `.sec-row[data-i="5"]`;+ await sp.click(`${ms} .sec-tag-in`); await sp.keyboard.type('offline'); await sp.keyboard.press('Enter'); await settle(sp, 200);+ check('the snapshot adds a tag with no server', JSON.stringify(await sp.$$eval(`${ms} .sec-tag`, e => e.map(x => x.firstChild.textContent))) === JSON.stringify(['misc', X32, 'offline']));+ await sp.evaluate(() => document.activeElement.blur()); await undo(sp);+ check('and takes it back with Ctrl+Z', JSON.stringify(await sp.$$eval(`${ms} .sec-tag`, e => e.map(x => x.firstChild.textContent))) === JSON.stringify(['misc', X32]));+ await sp.close();+}+ // ── Cleanup: always, however the run ends ── // The normal path, an external SIGINT/SIGTERM (Ctrl+C, a CI timeout) and the watchdog // all come through here, so no server, Chrome or temp dir is left behind.