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.61.0
9 files changed
+1132−228
README.md+22−5@@ -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, per-tag totals when the plan has tags (since 0.60.0: items, hours and share, by each row's own tags plus its section's, with a "no tag" line), 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. 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 (since 0.60.0, on rows as well as sections). 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, or remove it, each row showing how many rows the section holds and the dates they span. Since 0.61.0 a section moves by dragging its row (or Alt+Up / Alt+Down on a focused row), and its name and aside are edited by clicking them. 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 (since 0.60.0, on rows as well as sections). Since 0.61.0 it is where a tag is made before anything carries it (New tag) and where a tag gets a colour. 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. @@ -183,6 +183,21 @@ removing a tag there reaches the rows too. In the chart a row's own tags follow as quiet `#tag` text, which gives way before the name ever does, and they are on its tooltip. +Since 0.61.0 tags are first-class. A plan can carry a tag registry, `project.tags`, a+list of `{"name": "Tech", "color": "#8c6bf7"}`: it holds a tag's colour, and tags nothing+carries yet. The Tags card on Setup has a **New tag** box (the tag is listed as "0+sections" and suggested in every tag box, but gets no legend chip until something carries+it, since it would filter to nothing), click-to-rename on every tag's name, and a swatch+that opens a palette: the three brand colours first (teal `#00b8b1`, purple `#8c6bf7`,+blue `#64abff`), then coral `#ff8a7a`, amber `#e8b64a`, mint `#5fd7a3`, rose `#ec7ab8`,+indigo `#6f7cff` and slate `#8b9bb4`, a hex box, and No colour. A coloured tag shows its+colour wherever it is a chip or a pill (legend, section headers, Table pills, Setup,+editors, Analytics, and the `#tag` text on chart rows): a light fill of the colour, with+text and border walked to 3:1 contrast against the page on either theme. Bars keep their+section's colour. Colours are lowercase `#rrggbb`, names follow the tag rules above, and a+plan that never uses the registry carries no `project.tags` key. Registry changes are+logged as `tag.create`, `tag.rename`, `tag.color` and `tag.delete`.+ Omit `color` on a phase and it follows the Adom brand ramp, which adapts between dark and print. See [SKILL.md](SKILL.md) for the full field reference and the ramp values. ## Sections are phases@@ -195,9 +210,9 @@ table's phase column and every phase dropdown at once. Dragging a lane reorders `phases[]` itself, which is why the legend and the chart can no longer disagree. The **Setup** tab is the editor (since 0.58.0; until then it was a modal behind a-**Sections** button in the top bar): add a section, rename it, give it an aside,-recolour it from a brand-safe palette (or a `#rrggbb` of your own), reorder it, or-remove it. Each section's row shows how many rows it holds and the first and last+**Sections** button in the top bar): add a section, rename it or give it an aside by+clicking the text, recolour it from a brand-safe palette (or a `#rrggbb` of your own),+drag its row to reorder it (or Alt+Up / Alt+Down with the row focused), or remove it. Each section's row shows how many rows it holds and the first and last dates among them. An empty section goes on its own; one with rows in it makes you say where they land, so nothing is ever orphaned. Every change is one Ctrl+Z and one changelog line.@@ -385,7 +400,9 @@ table, the item editor or the batch editor are taken exactly as typed. | Gesture | Action | |---------|--------|-| Gantt / Table / Analytics / Setup | Switch lens. Pinned to the left of the header so it never moves. Setup is where sections are added, renamed, recoloured, reordered and removed |+| Gantt / Table / Analytics / Setup | Switch lens. Pinned to the left of the header so it never moves. Setup is where sections are added, renamed, recoloured, reordered and removed, and where tags are made, coloured, renamed and removed |+| Drag a section row on Setup | Move the section; a line shows where it lands. Alt+Up / Alt+Down move a focused row. One Ctrl+Z |+| Click a section's name or aside on Setup | Edit it in place: Enter or click away commits, Escape cancels | | Fit | Fit the whole plan into the pane, no horizontal scrolling | | Auto-schedule | Push every task out to its dependencies, finish to start, skipping weekends and US federal holidays. Never pulls work earlier, so intentional gaps survive. One undo. Disabled when the plan has no dependencies | | Critical | Light up the longest run of work inside every section, cancelled work left out. On/off, and it recomputes as the plan changes |
SKILL.md+62−14@@ -104,6 +104,7 @@ Create a JSON file with this structure: | appName | string | no | Shown in the brand block at the far left, the one place the header names the plan. With no appName the brand shows the title (it used to show the literal "adom-gantt"). A PRINTED chart carries the title and subtitle in a print-only line above the header, since tooltips do not print | | nextUid | number | no | The uid counter. Written and owned by the server, never by you. See uid below | | view | object | no | The author's intended default view, see below |+| tags | `{name, color?}[]` | no | The tag registry (0.61.0): the plan's tags that have a colour, and tags nothing carries yet. `name` follows the tag rules below; `color` is stored as lowercase `#rrggbb` (`#abc` is widened, anything else is dropped and the entry kept); unique without regard to case, at most 200. A tag on a section or row that is not listed here is still a tag: the registry adds colour and unused tags, it never gates them. Absent when empty: a plan that never used it carries no key | **project.view** (optional) sets how the chart opens. It applies to the HTML export, to a freshly served chart, and to any browser with no saved preference. In the live app a@@ -136,7 +137,7 @@ hero image that invited the click. | uid | string | assigned | The phase's permanent id, from the same counter rows use. The server writes it | | lead | string | no | A short aside shown beside the section name, e.g. "the long pole (Adonis)". Secondary text, never the name | | color | hex string | no | Overrides the brand ramp for this phase. An explicit colour is theme-INVARIANT by design: it is the colour someone chose, so it is the same in light, dark and print. Omit it to stay theme-aware, see Phase colors below |-| tags | string[] | no | Short labels for the section, e.g. `["Tech"]`. Trimmed, de-duplicated without regard to case (the first spelling wins), each capped at 32 characters and the list at 12; anything that is not a string is dropped. A `;` inside a tag becomes a space (0.60.0), since `;` separates tags in the CSV. Absent means none, and the server never writes an empty list. No colour of their own |+| tags | string[] | no | Short labels for the section, e.g. `["Tech"]`. Trimmed, de-duplicated without regard to case (the first spelling wins), each capped at 32 characters and the list at 12; anything that is not a string is dropped. A `;` inside a tag becomes a space (0.60.0), since `;` separates tags in the CSV. Absent means none, and the server never writes an empty list. A tag's colour, when it has one, is kept in `project.tags`, not here | | start | YYYY-MM | yes | Phase start month | | end | YYYY-MM | yes | Phase end month | @@ -419,15 +420,29 @@ STRUCTURE rather than its work. Today that is the sections: one row per entry in rows it holds and the first and last dates among them (a task's start and end, a milestone's date; a section with no dated rows says "no dates"). From there a section is added (the name box at the top; the new one goes on the end and is scrolled to), renamed-and given an aside (Edit), recoloured (the swatch: a brand-safe palette, Theme for the-adaptive ramp, or a `#rrggbb`), moved up or down, or removed. Removing a section with rows-in it asks where they go first; nothing is ever orphaned.+and given an aside, recoloured (the swatch: a brand-safe palette, Theme for the adaptive+ramp, or a `#rrggbb`), moved, or removed. Removing a section with rows in it asks where+they go first; nothing is ever orphaned.++Since 0.61.0 a section is **moved by dragging its row**: press anywhere on the row that is+not a field or a button (the grip at its left says so) and move; a line between two rows+shows where it will land, and the drop is one reorder, the same one the old arrow buttons+made (one Ctrl+Z, one `phase.reorder` event, and every row's section index, the collapse+set and the legend follow). A press that moves less than five pixels is a click, not a+drag. A mouse drags from anywhere on the row, a finger from the grip. With a row focused,+**Alt+Up** and **Alt+Down** move it, and the row's tooltip says so. The up and down arrows+are gone. The **name and the aside are edited by clicking them**: the text becomes a box+in the same spot, selected; Enter or clicking away commits, Escape cancels, and a name+another section has (any case) or an empty one is refused with a message and the box stays+open. An empty aside shows a quiet "add an aside" on hover. The Edit button and its form+are gone; a name and an aside are now one Ctrl+Z each. It replaced the **Sections** button that sat in the header through 0.57.1 and opened the same editor as a modal. The operations are the same functions and write the same things: every change is one Ctrl+Z (Ctrl+Z on the page repaints the page) and one changelog event with `via: "sections"` (`phase.create`, `phase.rename`, `phase.lead`, `phase.color`,-`phase.reorder`, `phase.delete`). One row editor is open at a time, and Escape cancels it.+`phase.reorder`, `phase.delete`). One row editor is open at a time (opening another commits+a name or aside being edited, and drops a Remove form or a palette), and Escape cancels it. The legend and the chart-only controls are hidden here, as on Analytics. The lens is a saved preference like the others, so a refresh on Setup comes back to Setup, and `project.view.mode: "setup"` opens a plan on it. It works in an exported HTML snapshot too.@@ -439,7 +454,8 @@ 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+first appears, with how many sections carry it, and can rename a tag everywhere (click its+name and type over it, since 0.61.0; Enter or clicking away commits, Escape cancels; 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:@@ -455,12 +471,34 @@ A rename moves a reader's tag filter with it, and its Ctrl+Z moves the filter ba sections, 1 row"), and Rename and Remove reach every row that carries the tag as well as every section, still as one Ctrl+Z and one changelog batch (a `tags` line per row, a `phase.tags` line per section). 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.+open row editor on the page (a name or aside being edited, Remove, a palette, a tag+rename) wherever the focus is.++**New tags and tag colours (0.61.0).** Tags are first-class now, in a registry,+`project.tags`. The Tags card has a **New tag** box (Enter or Add tag): the tag is made+with nothing carrying it, listed as "0 sections", and offered as a suggestion in every tag+box; a tag the plan already has, in any case, is refused with a message. An unused tag has+no legend chip, because choosing it would filter to nothing, and it is not in the+Analytics per-tag card; it gets both the moment a section or row carries it. Each tag row+has a **swatch**: it opens a palette of the three brand colours first (teal `#00b8b1`,+purple `#8c6bf7`, blue `#64abff`), then six that sit with them (coral `#ff8a7a`, amber+`#e8b64a`, mint `#5fd7a3`, rose `#ec7ab8`, indigo `#6f7cff`, slate `#8b9bb4`), a hex box+(`#rgb` or `#rrggbb`, with or without the `#`; anything else is refused with a message) and+**No colour**. Each of these is one Ctrl+Z and one save. A rename everywhere carries the+tag's colour to the new name, and a remove everywhere takes the tag out of the registry+too (its Ctrl+Z brings the colour back). This reverses the rule through 0.60.0 that tags+carry no colour: the colour is drawn on the tag's chips and pills only, never on a bar, so+a bar still means its section. Wherever a coloured tag is a chip or a pill (the legend's+tag chips, the section headers in the chart, the Table's Tags pills, the Setup chips, the+editors' chips, the Analytics per-tag card, and the quiet `#tag` text on chart rows) it+takes a low-alpha fill of the colour and, for its text and border, the colour walked to+3:1 contrast against what it sits on, by the same routine the dependency arrows use (hue+held, lightness walked away from the page), recomputed on a theme change. A tag with no+colour looks as it always did.++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.@@ -551,7 +589,16 @@ Each event records the action, the item, the before and after dates, the day shi `start-revert`, `auto-schedule`, `undo`). Actions are `dates`, `plan.shift`, `auto-schedule`, `milestone`, `milestone.rename`,-`note`, `owner`, `status`, `desc`, `tags`, `phase.tags`, `dep.add`, `dep.remove` and `reorder`.+`note`, `owner`, `status`, `desc`, `tags`, `phase.tags`, `tag.create`, `tag.rename`,+`tag.color`, `tag.delete`, `dep.add`, `dep.remove` and `reorder`.++The `tag.*` actions (0.61.0) are the tag registry's own: `tag.create` (New tag; `to.color`+when it was made with one) reads `Drew created the tag "someday"`, `tag.rename` reads+`Drew renamed the tag "someday" to "later"`, `tag.color` reads `Drew coloured the tag+"Tech" #8c6bf7` (or `recoloured ... #a -> #b`, or `cleared the colour on the tag ...`), and+`tag.delete` reads `Drew removed the tag "later" from the plan's tag list`. A rename or a+remove everywhere also writes its `tags` and `phase.tags` lines as before. Giving a colour+to a tag the plan already carries is a `tag.color`, not a `tag.create`. A tag edit carries the whole list on both sides. A row's is `{"action":"tags","uid":"00042","id":"etch","name":"Etch the cards","from":{"tags":[]},"to":{"tags":["long-lead"]},"via":"item-editor"}`@@ -694,6 +741,7 @@ The sidecar is at `v: 4` and everything in it is keyed by `uid`: | deleted | a list of uids the user removed. Those uids are retired and never reissued | | added | rows the user created, each carrying its own `uid` | | phases | `{i, start, end, tags}`, keyed by phase index, because a phase is not a row. A current client writes the whole section list (`name`, `uid`, `lead`, `color` too) |+| tags | the tag registry, `{name, color?}[]`, the same shape as `project.tags` (0.61.0). Written once the registry has been touched in the app. Absent: the data file's registry stands. `[]`: it was emptied, and the merged plan carries no `project.tags` | An absent field means the sidecar has nothing to say and the data file's value stands; an empty one means the user cleared it. `tags` follows the same rule: no key@@ -718,7 +766,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 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. The `row tags` flow (0.60.0) drives every row-tag edit path (the item editor, the table's Tags cell, the New panel and its memory, the batch panel's add and remove, a paste, rename and remove everywhere on Setup) against `/export/json` with each one's undo and redo, checks the `tags` changelog lines and their `via`, the effective-tag filter's row counts in the chart and the table, the Analytics per-tag card against hours worked out by hand from its seed, a cleared data-file tag staying cleared across a reload, the Tags column's sort and text filter, CSV out and back in through the Import menu, no row name cut for its tags at 393 and 1440, and showing, filtering and editing row tags 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. Since 0.61.0 it drags a row with real mouse events (row 3 to the top, checked against `/export/json` for the order and every row's section, one `phase.reorder` event, undo and redo), checks that a 2px press is a click, Alt+Up/Down on a focused row, and the click-to-edit name and aside: Enter, a click away, Escape, a duplicate and an empty name refused. 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, with a tag registry. Since 0.61.0 it also drives New tag (and its refusal), a colour from the palette and from the hex box, No colour, the colour on a legend chip, a header chip and a Table pill by computed style on both themes (fill and a 3:1 ink), rename and remove everywhere carrying the registry, the `tag.*` changelog lines, and the registry and an unused tag across a reload. The `row tags` flow (0.60.0) drives every row-tag edit path (the item editor, the table's Tags cell, the New panel and its memory, the batch panel's add and remove, a paste, rename and remove everywhere on Setup) against `/export/json` with each one's undo and redo, checks the `tags` changelog lines and their `via`, the effective-tag filter's row counts in the chart and the table, the Analytics per-tag card against hours worked out by hand from its seed, a cleared data-file tag staying cleared across a reload, the Tags column's sort and text filter, CSV out and back in through the Import menu, no row name cut for its tags at 393 and 1440, and showing, filtering and editing row tags in an exported snapshot. `node test/server_test.mjs` is the server's own suite (ports 8940 up). Ports are checked free before use and the server answering /health must be the one the suite spawned. `--keep` leaves screenshots in `test/out/`; `--plan <file>` also runs the header and legend checks against a real plan, COPIED to a temp dir first. It exits non-zero on any failure. ## Example
docs/BUILD-NOTES.md+70@@ -3,6 +3,76 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-01 (0.61.0, tag colours, New tag, drag to reorder, click to edit)++Three asks from Drew on the Setup page: make tags on the fly, give them colours, and lose+the little arrows and the Edit button.++**A tag registry, not a second tag system.** `project.tags` is `[{name, color?}]`, read by+one normaliser on each side (`normalizeTagRegistry`: the tag-name rules, a colour kept only+as lowercase `#rrggbb`, unique without regard to case, 200 at most). It ADDS to the tags+the sections and rows carry and never gates them: a carried tag that the registry does not+list is still a tag, and the registry holds only two kinds of fact, a colour and "this tag+exists although nothing wears it". `planTags()` appends the unused ones after the carried+ones and marks each entry `used`; the legend, the filter prune and the Analytics card read+`usedTags()`, so an unused tag is listed and suggested but never filters to nothing.+Decision: an unused tag gets no legend chip (and no Analytics line). The sidecar carries+a top-level `tags` only once the registry has been touched in the app (`regInSidecar`);+absent means the data file's registry stands, `[]` means it was emptied, and the merge then+writes no `project.tags` key, so a plan that never used it exports exactly as before.++**Undo and the changelog.** New tag and a colour are a `tag.reg` undo entry (the registry+as it was). Rename and remove everywhere take the registry entry with them inside their+existing `phase.tags` entry (`sn.reg`), so the colour follows a rename and comes back with+the undo of a remove, still one Ctrl+Z. Each entry has a client-only `_k`, like+`phaseKey`, and `diffAndLog` diffs the registry by it, so a rename logs `tag.rename` and not+a delete plus a create. An entry made only to hold a colour for a tag the plan already+carries logs `tag.color`, not `tag.create`; that includes the undo of a remove everywhere,+which brings back a coloured entry for a tag that is carried again.++**The colour on a chip.** This reverses the 0.59.0 rule that tags have no colour; the+colour is on chips and pills only, never on a bar, so a bar still means its section. The+dependency-arrow walk was split out of `depStroke` as `contrastWalk(t, bg, floor)`;+`tagPaint(hex)` uses it with the 3:1 floor against the FILL over the page (the 14% fill is+what the text actually sits on), memoised per theme like the arrows. Chips carry+`data-tc` and two custom properties, `--tag-ink` and `--tag-fill`; one CSS rule per chip+shape reads them, and the theme button repaints every `[data-tc]` (`repaintTagInks`). The+`#tag` text on a chart row became one span per tag so each can take its own ink; its+textContent is unchanged. The six non-brand palette colours were picked to sit at roughly+the brand trio's lightness so all nine read on the dark page untouched; on paper the walk+deepens the light ones (amber, mint) and leaves the rest.++**Drag to reorder.** Pointer events on the list, wired once (`wireSecList`): a press that+is not on a field or a button records a candidate, five pixels of movement starts the+drag, a 2px accent line marks the slot, and the drop is one `reorderPhases(from, to)`, the+exact call the arrows made, so the undo entry, the `phase.reorder` event and the remap of+row indexes, the collapse set and the legend are the arrows' own. A mouse drags from+anywhere on the row; touch drags from the grip only, so a swipe still scrolls the list.+`renderSections` does nothing mid-drag. Alt+Up and Alt+Down move a focused row (rows are+focusable now) and keep the focus on it.++**Click to edit, and the click that a blur commit used to eat.** The name and the aside are+`role="button"` spans; the pointerdown records which field, the pointerup opens it, so a+repaint between press and release cannot lose the click. The box commits on Enter or+blur, but a blur caused by a press waits for that press's click (`afterPointer`) before it+commits, because committing repaints the list and would otherwise take the clicked+control (a swatch, Remove, another name) out from under its own click. Opening any other+row editor first commits an open name or aside (`settleSecEditors`); a refused value (a+taken or empty name, the existing message) keeps the box open and blocks the other editor.+A name and an aside are now one Ctrl+Z each; the old Edit form saved both as one. The Tags+card's click-to-rename uses the same press record and `afterPointer`.++**Tests.** The `sections` flow drags row 3 to the top with real mouse events and checks+the saved order and every row's section against `/export/json`, one `phase.reorder` event,+undo and redo, a 2px press as a click, Alt+Up/Down, and every click-to-edit path. The+`tags` flow adds New tag, the palette and the hex box, the colour by computed style on a+legend chip, a header chip and a Table pill on both themes, rename and remove carrying the+registry, the `tag.*` lines, and a reload. The coverage manifest drops the arrows, Edit,+Save, Rename and its two buttons, and pins the name and aside fields, the swatch, the nine+palette colours, the hex box, No colour, New tag and Add tag. `server_test` gains the+registry: normalisation, absent stays absent, `[]` empties, `/compact`, `/data`,+`/import/json` round trip, the HTML snapshot, and every `tag.*` line.+ ## Build notes, 2026-09-30 (0.60.0, tags on tasks and milestones) Checkpoint 3 of three. The server already stored, merged, exported and logged
gantt.html+590−143@@ -1245,19 +1245,42 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 /* A header row over the list, in the analytics table's type, so the two numbers on each row say what they are once rather than on every row. */ .sec-cols { display: flex; align-items: center; gap: 10px; padding: 0 8px 5px; border-bottom: 1px solid var(--border); font-family: 'Familjen Grotesk', sans-serif; font-size: 10px; font-weight: 600; color: var(--text2); }-.sec-cols .c-name { padding-left: 25px; }+.sec-cols .c-name { padding-left: var(--sec-indent); } .sec-cols .sec-count, .sec-cols .sec-dates { font-family: inherit; font-size: inherit; color: inherit; } .sec-cols .c-name { flex: 1 1 200px; }-.sec-list { padding: 4px 0 0; }-.sec-row { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px; padding: 6px 8px; border-radius: 7px; border: 1px solid transparent; }+.setup-view { --sec-indent: 47px; }+.sec-list { padding: 4px 0 0; position: relative; }+/* 0.61.0: the whole row is the drag handle (anything on it that is not a field or a+ button), the grip at its left says so, and a focused row moves with Alt+Up/Down. */+.sec-row { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px; padding: 6px 8px; border-radius: 7px; border: 1px solid transparent; cursor: grab; position: relative; }+.sec-row:focus { outline: none; }+.sec-row:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }+.sec-grip { width: 12px; height: 15px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: var(--text3); opacity: 0.6; touch-action: none; }+.sec-row:hover .sec-grip, .sec-row:focus-visible .sec-grip { opacity: 1; color: var(--text2); }+.sec-list.sec-drag-on, .sec-list.sec-drag-on * { cursor: grabbing !important; user-select: none; }+.sec-row.sec-dragging { opacity: 0.4; }+.sec-drop { position: absolute; left: 6px; right: 6px; height: 2px; margin-top: -1px; background: var(--accent); border-radius: 2px; pointer-events: none; z-index: 2; }+.sec-drop::before { content: ''; position: absolute; left: -4px; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); } .sec-row + .sec-row { border-top-color: rgba(48,54,61,0.15); border-top-left-radius: 0; border-top-right-radius: 0; } .sec-row:hover { background: var(--row-hover); } .sec-row.sec-editing { border-color: var(--border); background: var(--elevated); border-radius: 7px; } .sec-row.sec-flash { animation: secFlash 1.4s ease-out; } @keyframes secFlash { from { background: rgba(0,184,177,0.18); } to { background: transparent; } } .sec-sw { width: 15px; height: 15px; border-radius: 4px; border: 1px solid var(--border); flex-shrink: 0; cursor: pointer; padding: 0; }-.sec-name { flex: 1 1 200px; min-width: 0; font-size: 12.5px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }-.sec-name .sec-lead { color: var(--text2); font-size: 0.86em; margin-left: 7px; }+.sec-name { flex: 1 1 200px; min-width: 0; font-size: 12.5px; color: var(--text); overflow: hidden; white-space: nowrap; display: flex; align-items: baseline; gap: 4px; }+/* Click to edit (0.61.0): the name and the aside are fields. A faint box on hover says+ so; the space after them on the row is still the drag handle. */+.sec-nm-t, .sec-ld-t { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: text; border-radius: 4px; padding: 1px 4px; border: 1px solid transparent; }+.sec-nm-t { margin-left: -5px; }+.sec-ld-t { color: var(--text2); font-size: 0.86em; flex-shrink: 2; }+.sec-nm-t:hover, .sec-ld-t:hover { border-color: var(--border); background: var(--surface); }+.sec-nm-t:focus-visible, .sec-ld-t:focus-visible { outline: none; border-color: var(--accent); }+.sec-ld-t.empty { color: var(--text3); font-style: italic; opacity: 0; }+.sec-row:hover .sec-ld-t.empty, .sec-row:focus-within .sec-ld-t.empty, .sec-row:focus .sec-ld-t.empty { opacity: 1; }+@media (hover: none) { .sec-ld-t.empty { opacity: 0.55; } }+.setup-view .sec-name input[type="text"] { flex: 1 1 140px; min-width: 0; font-size: 12px; padding: 1px 6px; }+.setup-view .sec-name input.sec-nm { margin-left: -5px; }+.setup-view input[type="text"].bad { border-color: #f85149; } .sec-count, .sec-dates { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text2); flex-shrink: 0; text-align: right; white-space: nowrap; } .sec-count { width: 58px; } .sec-dates { width: 196px; }@@ -1272,17 +1295,46 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .sec-btn[disabled] { opacity: 0.35; cursor: default; } .sec-btn[disabled]:hover { background: none; border-color: transparent; color: var(--text2); } .sec-btn.danger:hover { color: #f85149; }-.sec-pal { display: flex; gap: 5px; flex-wrap: wrap; align-items: center; padding: 6px 2px 2px 25px; }+.sec-pal { display: flex; gap: 5px; flex-wrap: wrap; align-items: center; padding: 6px 2px 2px var(--sec-indent); } .sec-chip { width: 18px; height: 18px; border-radius: 5px; border: 2px solid transparent; cursor: pointer; padding: 0; } .sec-chip.on { border-color: var(--text); } .sec-msg { flex: 1 1 100%; color: #f85149; font-size: 11px; padding-top: 2px; }+.sec-row > .sec-msg { padding-left: var(--sec-indent); } .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. */+ text colours, never a section colour. From 0.61.0 a tag may have a colour of its own:+ then every chip and pill of it (the [data-tc] rule below) takes a low-alpha fill of the+ colour and its ink (the colour walked to 3:1 against the page) for text and border. */ .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; }+/* A coloured tag (0.61.0), on every chip and pill shape at once. */+.sec-tag[data-tc], .lane-tag[data-tc], .data-table .row-tag[data-tc] { color: var(--tag-ink); border-color: color-mix(in srgb, var(--tag-ink) 55%, transparent); background: var(--tag-fill); }+.data-table .row-tag.inh[data-tc] { background: transparent; border-color: color-mix(in srgb, var(--tag-ink) 45%, transparent); }+.sec-tag[data-tc] .sec-tag-x, .sec-tag[data-tc] .tb-x, .sec-tag[data-tc] .bt-x, .sec-tag[data-tc] small { color: inherit; opacity: 0.75; }+.label-row .row-name .row-tags .rt[data-tc] { color: var(--tag-ink); }+.legend-item.tag-chip[data-tc] { color: var(--tag-ink); border-color: color-mix(in srgb, var(--tag-ink) 55%, transparent); background: var(--tag-fill); }+.legend-item.tag-chip[data-tc] .tc-hash, .legend-item.tag-chip[data-tc] .tc-n { color: inherit; opacity: 0.75; }+.legend-item.tag-chip[data-tc]:hover { border-color: var(--tag-ink); }+.legend-item.tag-chip.on[data-tc] { color: var(--text); border-color: var(--tag-ink); background: color-mix(in srgb, var(--tag-ink) 26%, transparent); }+.legend-item.tag-chip.on[data-tc] .tc-hash, .legend-item.tag-chip.on[data-tc] .tc-n { color: var(--tag-ink); opacity: 1; }+.tag-nm-t { cursor: text; }+.tag-nm-t:hover { border-color: var(--text3); }+.tag-nm-t[data-tc]:hover { border-color: var(--tag-ink); }+.tag-nm-t:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }+.tag-sw { width: 15px; height: 15px; border-radius: 4px; border: 1px solid var(--border); flex-shrink: 0; cursor: pointer; padding: 0; background: none; }+.tag-sw.none { border-style: dashed; border-color: var(--text3); }+.tag-sw:hover { border-color: var(--text2); }+.tag-pal { display: flex; gap: 5px; flex-wrap: wrap; align-items: center; width: 100%; padding: 6px 2px 2px 25px; }+.tag-pal-sep { width: 1px; height: 16px; background: var(--border); margin: 0 3px; }+.tag-pc { width: 18px; height: 18px; border-radius: 5px; border: 2px solid transparent; cursor: pointer; padding: 0; }+.tag-pc.on { border-color: var(--text); }+.tag-pal .tag-hex { width: 88px; margin-left: 6px; }+.tag-pal .tag-btn[disabled] { opacity: 0.4; cursor: default; }+.tag-pal .sec-msg { padding-left: 0; }+.tag-n.unused { color: var(--text3); }+.tag-add { margin-top: 2px; }+.sec-tags { order: 8; width: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 0 0 0 var(--sec-indent); 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); }@@ -1304,9 +1356,7 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .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-name .tag-nm { width: 100%; max-width: 260px; min-width: 0; font-size: 11.5px; padding: 1px 6px; } .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; }@@ -1330,8 +1380,9 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 @media (max-width: 640px) { .su-scroll { padding: 12px 10px 32px; } .sec-cols { display: none; }- .sec-name { flex-basis: calc(100% - 30px); }- .sec-count { width: auto; margin-left: 25px; text-align: left; }+ .setup-view { --sec-indent: 22px; }+ .sec-name { flex-basis: calc(100% - 52px); }+ .sec-count { width: auto; margin-left: var(--sec-indent); text-align: left; } .sec-dates { width: auto; text-align: left; } .sec-acts { margin-left: auto; } .sec-add input[type="text"] { max-width: none; }@@ -1587,19 +1638,24 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va <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 and rows, for finding and filtering them: choose one in the legend to see only the rows that carry it, on themselves or through their section. They have no colour of their own. Add them on each section below, and on a row in its editor or the table's Tags column; rename or remove one everywhere, sections and rows alike, here.</p>+ <p class="an-sub">Short labels on sections and rows, for finding and filtering them: choose one in the legend to see only the rows that carry it, on themselves or through their section. Make one here, or type it on a section below, on a row in its editor, or in the table's Tags column. Click a swatch to give a tag a colour, click a name to rename it everywhere.</p>+ <div class="sec-add tag-add">+ <input type="text" id="tagNew" placeholder="New tag" maxlength="32" autocomplete="off" aria-label="New tag">+ <button class="de-btn secondary" id="tagAdd">Add tag</button>+ </div>+ <div class="sec-err tag-err" style="display:none"></div> <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>+ <p class="an-sub">The lanes of this plan, in the order the chart and the table show them. Drag a row to move it; click a name or an aside to edit it. Every change here is one Ctrl+Z.</p> <div class="sec-add"> <input type="text" id="secNew" placeholder="New section name" maxlength="60" aria-label="New section name"> <button class="de-btn primary" id="secAdd">Add section</button> </div> <div class="sec-err" style="display:none"></div>- <div class="sec-cols" aria-hidden="true"><span class="c-name">Section</span><span class="sec-count">Rows</span><span class="sec-dates">Dates</span><span class="sec-acts" style="visibility:hidden"><span class="sec-ghost">↑</span><span class="sec-ghost">↓</span><span class="sec-ghost">Edit</span><span class="sec-ghost">Remove</span></span></div>+ <div class="sec-cols" aria-hidden="true"><span class="c-name">Section</span><span class="sec-count">Rows</span><span class="sec-dates">Dates</span><span class="sec-acts" style="visibility:hidden"><span class="sec-ghost">Remove</span></span></div> <div class="sec-list" role="list"></div> </div> </div></div>@@ -1651,7 +1707,9 @@ const { project, phases, items } = projData; // is not a string, turn `;` (the CSV separator) into a space, 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.+// A tag MAY carry a colour (0.61.0), kept in the registry below and set on the Setup page.+// Until 0.61.0 tags had none, so colour on the chart meant the section only; a tag's+// colour is drawn on its chip or pill alone, never on a bar, so a bar still means its section. const TAG_MAX_LEN = 32, TAG_MAX_COUNT = 12; function normalizeTags(v) { if (!Array.isArray(v)) return undefined;@@ -1679,6 +1737,49 @@ function tidyTags(o){ phases.forEach(tidyTags); items.forEach(tidyTags); +// ── The tag registry (0.61.0) ──+//+// project.tags: [{name, color?}], the server's normalizeTagRegistry line for line. It is+// where a tag keeps its colour and where a tag nothing carries yet lives, so "New tag" on+// the Setup page has somewhere to put one. A tag on a section or a row that the registry+// does not list is still a tag: the registry adds to the plan's tags, it never gates them.+// A colour is lowercase #rrggbb or absent. `_k` is a client-only identity (like phaseKey),+// so a rename is seen by the changelog diff as a rename and not a delete plus a create; it+// never reaches the disk.+const TAG_REG_MAX=200;+function normTagColor(v){+ if(typeof v!=='string')return '';+ let c=v.trim().toLowerCase(); if(!c)return '';+ if(c[0]!=='#')c='#'+c;+ if(/^#[0-9a-f]{3}$/.test(c))c='#'+c[1]+c[1]+c[2]+c[2]+c[3]+c[3];+ return /^#[0-9a-f]{6}$/.test(c)?c:'';+}+function normalizeTagRegistry(v){+ if(!Array.isArray(v))return undefined;+ const out=[], seen=new Set();+ for(const e of v){+ const raw=typeof e==='string'?e:(e&&typeof e==='object'?e.name:undefined);+ if(typeof raw!=='string')continue;+ const s=raw.replace(/;/g,' ').trim().slice(0,TAG_MAX_LEN).trim();+ if(!s||seen.has(s.toLowerCase()))continue;+ seen.add(s.toLowerCase());+ const o={name:s};+ const c=e&&typeof e==='object'?normTagColor(e.color):'';+ if(c)o.color=c;+ out.push(o);+ if(out.length>=TAG_REG_MAX)break;+ }+ return out;+}+let regKeySeq=0;+const regEntry=e=>Object.assign({},e,{_k:e._k||('t'+(++regKeySeq))});+let tagReg=(normalizeTagRegistry(project&&project.tags)||[]).map(regEntry);+if(project&&'tags' in project){ if(tagReg.length)project.tags=tagReg.map(e=>{ const o={name:e.name}; if(e.color)o.color=e.color; return o; }); else delete project.tags; }+// Does the sidecar carry the registry? Once anything has touched it (or the sidecar already+// had one), yes, `[]` included, because to the server an absent `tags` means "keep the data+// file's" and an emptied registry has to say so.+let regInSidecar=false;+ // 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.@@ -3288,12 +3389,21 @@ function applySnapshot(sn){ sn.rows.forEach(([r,t])=>{ if(!items.includes(r))return; inv.rows.push([r,Array.isArray(r.tags)?r.tags.slice():undefined]); setRowTags(r,t); refreshRowTags(r); }); }- if(!inv.list.length&&!(inv.rows&&inv.rows.length))return null;+ if(sn.reg){ inv.reg=regSnap(); tagReg=sn.reg.map(e=>Object.assign({},e)); }+ if(!inv.list.length&&!(inv.rows&&inv.rows.length)&&!sn.reg)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; }+ // The tag registry as it was (0.61.0): a new tag, a colour.+ if(sn.type==='tag.reg'){+ const inv={type:'tag.reg',reg:regSnap()};+ tagReg=sn.reg.map(e=>Object.assign({},e));+ regInSidecar=true;+ 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};@@ -3792,6 +3902,9 @@ function getState(){ 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; o.tags=(p.tags||[]).slice(); return o;}),+ // The tag registry (0.61.0), only once something has touched it: absent tells the+ // server the data file's registry stands, so an untouched plan's sidecar says nothing.+ ...(regInSidecar?{tags:regOut()}:{}), order: items.filter(r=>!isDerivedGroup(r)).map(rowOrderKey), deleted: [...deletedKeys], added: addedItems.map(r=>{const o={};@@ -3823,6 +3936,8 @@ function applyState(state){ } const byUid=new Map(); items.forEach(r=>{ if(r.uid)byUid.set(r.uid,r); });+ // The tag registry (0.61.0): absent, the data file's stands; a list, even [], replaces it.+ if(Array.isArray(state.tags)){ tagReg=(normalizeTagRegistry(state.tags)||[]).map(regEntry); regInSidecar=true; } // How many sections the plan will have once this sidecar is applied. The row overrides // are applied BEFORE the section list is rebuilt below, so checking a row's saved phase // against phases[] here checked it against the DATA FILE's list. A row whose section@@ -3943,7 +4058,9 @@ function snapSemantic(){ // 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 tag registry (0.61.0), by entry identity, so a rename reads as one.+ const reg={};tagReg.forEach(e=>{reg[e._k]={name:e.name,color:e.color||''};});+ return {byId,ms,ph,reg,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@@ -4040,6 +4157,21 @@ function diffAndLog(via){ 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}}); });+ // The tag registry (0.61.0): tag.create, tag.rename, tag.color, tag.delete. An entry made+ // only to hold a colour for a tag the plan already carries is a colour, not a new tag.+ const curR=cur.reg||{},oldR=lastSnap.reg||{};+ const carried=new Set(usedTags().map(e=>e.key));+ Object.keys(oldR).forEach(k=>{ if(!curR[k])evs.push({action:'tag.delete',tag:oldR[k].name,from:{color:oldR[k].color}}); });+ Object.keys(curR).forEach(k=>{+ const a=oldR[k],b=curR[k];+ if(!a){+ if(b.color&&carried.has(tagKey(b.name)))evs.push({action:'tag.color',tag:b.name,from:{color:''},to:{color:b.color}});+ else if(b.color||!carried.has(tagKey(b.name)))evs.push({action:'tag.create',tag:b.name,to:{color:b.color}});+ return;+ }+ if(a.name!==b.name)evs.push({action:'tag.rename',tag:b.name,from:{name:a.name},to:{name:b.name}});+ if(a.color!==b.color)evs.push({action:'tag.color',tag:b.name,from:{color:a.color},to:{color:b.color}});+ }); 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.@@ -4125,7 +4257,7 @@ function flushLogQueue(){ // difference repaints what reads it: the legend chips and counts, the filter (a tag gone // from the plan leaves it), the suggestions. Cheap: a plan has tens of tags at most. let tagSig='';-function tagSigNow(){ return JSON.stringify(planTags().map(e=>[e.tag,e.n,e.items])); }+function tagSigNow(){ return JSON.stringify(planTags().map(e=>[e.tag,e.n,e.items,e.color])); } function scheduleSave(via){if(tagsReady&&tagSigNow()!==tagSig)afterTagsChange();diffAndLog(via||'edit');syncStartBtn();syncCollapseAllBtn();if(critOn())refreshCritical();syncPlanCtl();if(prefs.view==='analytics')renderAnalytics();else if(prefs.view==='setup')renderSections(true);clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},800);} // Both writes wait: the sidecar for its 800ms debounce and, since 0.54.0, the changelog // line for the save it belongs to. A tab closed inside that window would leave the edit in@@ -4404,6 +4536,12 @@ function effTags(r){ // Put a row's own list back exactly: an array (possibly empty, the sidecar's "cleared") // or no key at all. Undo and redo go through here so absent and [] survive the round trip. function setRowTags(r,v){ if(Array.isArray(v))r.tags=v.slice(); else delete r.tags; }+// The registry (0.61.0), read by key. regOut is its on-disk shape, without the `_k`.+function regOut(){ return tagReg.map(e=>{ const o={name:e.name}; if(e.color)o.color=e.color; return o; }); }+function regFind(t){ const k=tagKey(t); return tagReg.find(e=>tagKey(e.name)===k)||null; }+function regSnap(){ return tagReg.map(e=>Object.assign({},e)); }+function regSig(){ return JSON.stringify(tagReg.map(e=>[e._k,e.name,e.color||''])); }+function tagColor(t){ const e=regFind(t); return (e&&e.color)||''; } // Every tag in the plan, in the order it first appears going down the sections, with // how many sections carry it (`n`, which the legend fold and the prefix logic rely on // meaning sections), how many rows carry it on their own (`rows`, 0.60.0), and how many@@ -4422,12 +4560,19 @@ function planTags(){ // a row whose section index is out of range still has its own tags counted rows.forEach(r=>{ if(!phases[r.phase])ownTags(r).forEach(t=>ent(t).rows++); }); rows.forEach(r=>effTags(r).forEach(t=>{ const e=m.get(tagKey(t)); if(e)e.items++; }));- return [...m.values()];+ // Then the registry's tags that nothing carries yet (0.61.0), in registry order, so a tag+ // made with "New tag" is listed and suggested before anything wears it. `used` is what the+ // legend, the filter and the Analytics card go by: an unused tag would filter to nothing.+ tagReg.forEach(r=>{ const k=tagKey(r.name); if(!m.has(k))m.set(k,{tag:r.name,key:k,n:0,idx:[],rows:0,items:0}); });+ const out=[...m.values()];+ out.forEach(e=>{ e.color=tagColor(e.key); e.used=!!(e.n||e.rows); });+ return out; }+function usedTags(){ return planTags().filter(e=>e.used); } // "2 sections, 3 rows", leaving out a zero, for the Setup card and the chip titles. function tagCountText(e){ const parts=[]; if(e.n)parts.push(plural(e.n,'section')); if(e.rows)parts.push(plural(e.rows,'row'));- return parts.join(', ')||'unused';+ return parts.join(', ')||'0 sections'; } // 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,@@ -4468,7 +4613,7 @@ function phaseShown(i,ks){ } function pruneTagFilter(){ if(!tagsReady||!prefs.tagFilter.length)return false;- const have=new Map(planTags().map(e=>[e.key,e.tag]));+ const have=new Map(usedTags().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();@@ -4514,6 +4659,7 @@ function tagChip(e,on){ // sits in a section that does (0.60.0). Before rows had tags it counted sections, and // with row tags that number stopped answering "how much will I see". el.innerHTML=`<span class="tc-hash" aria-hidden="true">#</span><span class="tc-label">${esc(e.tag)}</span><span class="tc-n">${e.items}</span>`;+ paintTag(el,e.tag); const where=[e.n?plural(e.n,'section')+': '+e.idx.map(i=>phases[i].name).join(', '):'',e.rows?plural(e.rows,'row')+' tagged on their own':''].filter(Boolean).join('; '); el.title=(on?'Stop filtering by ':'Show only the rows tagged ')+e.tag+', '+plural(e.items,'row')+' ('+where+'). Choose more than one to see any of them. Your view only, it does not change the plan.'; el.addEventListener('click',()=>toggleTagFilter(e.tag));@@ -4535,7 +4681,7 @@ function rebuildLegend(){ [secToggle,tagClear].forEach(el=>{ if(el.isConnected)el.remove(); }); const first=collapseAllBtn.isConnected?collapseAllBtn.nextSibling:legendEl.firstChild; const put=el=>legendEl.insertBefore(el,first);- const tags=planTags(), ks=tagFilterKeys(), filtering=tags.length>0&&tagFilterOn();+ const tags=usedTags(), 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;@@ -4726,7 +4872,7 @@ function makeEditBtn(r){ // yields to them. Section tags are not repeated here; the section header carries those. function rowTagsHtml(r){ const t=ownTags(r); if(!t.length)return '';- return `<span class="row-tags" title="${esc('Tags: '+t.join(', '))}">${t.map(x=>'#'+esc(x)).join(' ')}</span>`;+ return `<span class="row-tags" title="${esc('Tags: '+t.join(', '))}">${t.map(x=>`<span class="rt"${tagAttrs(x)}>#${esc(x)}</span>`).join(' ')}</span>`; } function nameInner(r){ const nt='<span class="name-text">'+esc(r.name||'')+'</span>';@@ -5460,27 +5606,64 @@ function depStroke(css){ if(depStrokeMemo.has(key))return depStrokeMemo.get(key); let out=null; const t=tripleOf(css), bg=tripleOf('var(--bg)');+ if(t&&bg)out='rgb('+contrastWalk(t,bg,DEP_STROKE_FLOOR).join(',')+')';+ depStrokeMemo.set(key,out);+ return out;+}+// Steps 3 and 4 of the formula above, on its own so a tag chip (0.61.0) walks a colour+// exactly the way an arrow does: hue and saturation held, lightness walked away from+// `bg` in 2% steps until the ratio clears `floor`.+function contrastWalk(t,bg,floor){+ const lb=relLum(bg);+ if(contrastOf(relLum(t),lb)>=floor)return t;+ // Away from the page: on a dark page lightness goes up, on a light one it goes+ // down. A step of 2% is fine enough that the lift is never more than it has to+ // be and coarse enough that the walk is at most fifty iterations, once, memoised.+ const [h,s,l0]=rgbToHsl(t[0],t[1],t[2]);+ const up=lb<0.5, step=up?0.02:-0.02;+ let best=t;+ for(let l=l0+step; up?l<=1.0001:l>=-0.0001; l+=step){+ const c=hslToRgb(h,s,Math.max(0,Math.min(1,l)));+ best=c;+ if(contrastOf(relLum(c),lb)>=floor)break;+ }+ return best;+}+// ── A tag's colour on its chip (0.61.0) ──+//+// A tag with a colour is drawn in it wherever it is a chip or a pill: a low-alpha FILL of+// the colour itself, and the text and border in its INK, which is the colour walked by+// contrastWalk until it clears 3:1 (the arrows' floor) against what the text actually sits+// on, the fill over the page. So a pale amber reads on paper and a deep indigo reads on the+// dark theme, and the hue is still the one that was picked. Measured per theme and memoised+// like the arrows; the theme button repaints every coloured chip (repaintTagInks). A tag+// with no colour carries no data-tc and looks exactly as it did before colours existed.+const TAG_FILL_ALPHA=0.14, TAG_INK_FLOOR=3.0;+const tagInkMemo=new Map();+function tagPaint(hex){+ const key=(document.documentElement.getAttribute('data-theme')||'')+'|'+hex;+ if(tagInkMemo.has(key))return tagInkMemo.get(key);+ let out=null;+ const t=tripleOf(hex), bg=tripleOf('var(--bg)'); if(t&&bg){- const lb=relLum(bg);- if(contrastOf(relLum(t),lb)>=DEP_STROKE_FLOOR)out='rgb('+t.join(',')+')';- else{- // Away from the page: on a dark page lightness goes up, on a light one it goes- // down. A step of 2% is fine enough that the lift is never more than it has to- // be and coarse enough that the walk is at most fifty iterations, once, memoised.- const [h,s,l0]=rgbToHsl(t[0],t[1],t[2]);- const up=lb<0.5, step=up?0.02:-0.02;- let best=t;- for(let l=l0+step; up?l<=1.0001:l>=-0.0001; l+=step){- const c=hslToRgb(h,s,Math.max(0,Math.min(1,l)));- best=c;- if(contrastOf(relLum(c),lb)>=DEP_STROKE_FLOOR)break;- }- out='rgb('+best.join(',')+')';- }+ const under=bg.map((b,i)=>Math.round(b+(t[i]-b)*TAG_FILL_ALPHA));+ out={ink:'rgb('+contrastWalk(t,under,TAG_INK_FLOOR).join(',')+')',fill:'rgba('+t.join(',')+','+TAG_FILL_ALPHA+')'}; }- depStrokeMemo.set(key,out);+ tagInkMemo.set(key,out); return out; }+function applyTagPaint(el,hex){+ const p=hex&&tagPaint(hex);+ if(!p){ delete el.dataset.tc; el.style.removeProperty('--tag-ink'); el.style.removeProperty('--tag-fill'); return; }+ el.dataset.tc=hex; el.style.setProperty('--tag-ink',p.ink); el.style.setProperty('--tag-fill',p.fill);+}+// For DOM built node by node, and for markup built as a string (the attributes to splice in).+function paintTag(el,t){ applyTagPaint(el,tagColor(t)); }+function tagAttrs(t){+ const c=tagColor(t), p=c&&tagPaint(c);+ return p?` data-tc="${c}" style="--tag-ink:${p.ink};--tag-fill:${p.fill}"`:'';+}+function repaintTagInks(){ document.querySelectorAll('[data-tc]').forEach(el=>applyTagPaint(el,el.dataset.tc)); } // The colour an arrow out of this row should be. A row with no phase (which nothing // arrow-bearing has today) falls back to nothing, and nothing means the CSS default, // which is still the accent.@@ -6318,7 +6501,7 @@ function refreshHeaderRow(r){ 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.+// never the section's, or in the tag's own colour when it has one (0.61.0). // 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@@ -6329,7 +6512,7 @@ function fillLaneName(nm,name,lead,tags){ 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); });+ tags.forEach(x=>{ const c=document.createElement('span'); c.className='lane-tag'; c.textContent=x; paintTag(c,x); w.appendChild(c); }); nm.appendChild(w); } }@@ -6362,6 +6545,8 @@ function afterTagsChange(){ if(top&&!top.filter&&!top.viewFilter)top.viewFilter=fBefore; } syncTagList();+ // A tag's colour (0.61.0) is on every row label that carries it as "#tag" text.+ items.forEach(r=>{ if(r._nameSpan&&ownTags(r).length)r._nameSpan.innerHTML=nameInner(r); }); refreshSections(); applyTagView(); }@@ -6637,30 +6822,218 @@ 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.+ // New tag (0.61.0): a tag with nothing carrying it yet, kept in the registry so it is+ // listed here and suggested in every tag box. Refused when the plan already has it.+ const tIn=$('tagNew'), tBtn=$('tagAdd'), tErr=v.querySelector('.tag-err');+ const tSay=t=>{ tErr.textContent=t; tErr.style.display=t?'':'none'; };+ const tAdd=()=>{+ const nm=tIn.value.trim();+ if(!nm){ tIn.focus(); return; }+ const why=createTag(nm);+ if(why==='taken'){ tSay('There is already a tag called "'+nm.slice(0,TAG_MAX_LEN)+'".'); tIn.focus(); return; }+ if(why==='empty'){ tSay('That is not a tag. Type a word or two.'); tIn.focus(); return; }+ tIn.value=''; tSay(''); tIn.focus();+ const row=$('suTags').querySelector(`.tag-row[data-tag="${CSS.escape(tagKey(nm.replace(/;/g,' ').trim().slice(0,TAG_MAX_LEN).trim()))}"]`);+ if(row){ row.scrollIntoView({block:'nearest'}); row.classList.add('sec-flash'); }+ };+ tBtn.addEventListener('click',tAdd);+ tIn.addEventListener('keydown',e=>{ e.stopPropagation(); if(e.key==='Enter'){ e.preventDefault(); tAdd(); } else if(e.key==='Escape'){ e.preventDefault(); if(tIn.value)tIn.value=''; else tIn.blur(); tSay(''); } });+ tIn.addEventListener('input',()=>tSay(''));+ wireSecList(v.querySelector('.sec-list'));+ wireTagList($('suTags').querySelector('.tag-list'));+ // Escape closes whatever row editor is open on this page (a name or aside being edited,+ // Remove, the colour palette, a tag's rename box or palette), 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;+ const open=secRowOpen(), ren=tagEditing!==null||tagPalOpen!==null; if(!open&&!ren)return; e.preventDefault(); e.stopPropagation();- if(ren){ tagEditing=null; renderTagsCard(); }+ if(ren){ tagEditing=null; tagPalOpen=null; renderTagsCard(); } if(open)renderSections(); if(a&&a!==document.body&&!a.isConnected)try{ document.activeElement.blur(); }catch(_){} },true); }+// ── The section list's gestures (0.61.0) ──+//+// Drag to reorder: press anywhere on a row that is not a field or a button (the grip at its+// left says so) and move. Under five pixels of movement it is a click, not a drag. While it+// moves, a bar between two rows shows where it will land; the drop is ONE reorderPhases+// call, the very path the up and down arrows used, so one undo entry, one changelog event+// (phase.reorder), and the rows' section indexes, the collapse set and the legend follow+// it. A mouse drags from anywhere on the row; a finger drags from the grip only, so a+// swipe over the list still scrolls it. Alt+Up and Alt+Down move the focused row.+//+// Click to edit: a press and release on the name or the aside opens it in place (the+// pointerup does it, from what the pointerdown recorded, so a repaint between the two,+// a blur commit in another row say, cannot eat the click).+function wireSecList(list){+ if(!list)return;+ let press=null, drop=null;+ const MOVE=5;+ const rows=()=>[...list.querySelectorAll('.sec-row')];+ const stopDrag=()=>{+ if(drop){ drop.remove(); drop=null; }+ list.classList.remove('sec-drag-on');+ list.querySelectorAll('.sec-dragging').forEach(r=>r.classList.remove('sec-dragging'));+ };+ const place=y=>{+ const rs=rows(); let slot=rs.length;+ for(let j=0;j<rs.length;j++){ const b=rs[j].getBoundingClientRect(); if(y<b.top+b.height/2){ slot=j; break; } }+ const to=slot>press.from?slot-1:slot;+ press.to=to;+ if(to===press.from){ drop.style.display='none'; return; }+ drop.style.display='';+ const lr=list.getBoundingClientRect();+ const edge=slot<rs.length?rs[slot].getBoundingClientRect().top:rs[rs.length-1].getBoundingClientRect().bottom;+ drop.style.top=(edge-lr.top-1)+'px';+ // near the edge of the scroller, scroll it, so a long list can be dragged end to end+ const sc=$('suScroll'); if(sc){ const sr=sc.getBoundingClientRect();+ if(y<sr.top+36)sc.scrollTop-=14; else if(y>sr.bottom-36)sc.scrollTop+=14; }+ };+ list.addEventListener('pointerdown',e=>{+ if(e.button!==0)return;+ const row=e.target.closest('.sec-row'); if(!row||row.parentElement!==list)return;+ const fld=e.target.closest('.sec-nm-t,.sec-ld-t');+ if(fld){ press={kind:'field',k:row.dataset.k,f:fld.dataset.f,x:e.clientX,y:e.clientY,id:e.pointerId}; return; }+ if(e.target.closest('button,input,select,textarea,a,.sec-pal,.sec-msg,.sec-tag'))return;+ if(e.pointerType!=='mouse'&&!e.target.closest('.sec-grip'))return;+ press={kind:'drag',k:row.dataset.k,from:+row.dataset.i,to:+row.dataset.i,x:e.clientX,y:e.clientY,id:e.pointerId,on:false,row};+ });+ document.addEventListener('pointermove',e=>{+ if(!press||press.kind!=='drag'||e.pointerId!==press.id)return;+ if(!press.on){+ if(Math.hypot(e.clientX-press.x,e.clientY-press.y)<MOVE)return;+ if(!press.row.isConnected){ press=null; return; }+ press.on=true;+ try{ list.setPointerCapture&&press.row.setPointerCapture(e.pointerId); }catch(_){}+ try{ window.getSelection().removeAllRanges(); }catch(_){}+ list.classList.add('sec-drag-on'); press.row.classList.add('sec-dragging');+ drop=document.createElement('div'); drop.className='sec-drop'; drop.setAttribute('aria-hidden','true'); list.appendChild(drop);+ }+ e.preventDefault();+ place(e.clientY);+ });+ const end=(e,commit)=>{+ if(!press||e.pointerId!==press.id)return;+ const p=press; press=null;+ if(p.kind==='field'){+ if(commit&&Math.hypot(e.clientX-p.x,e.clientY-p.y)<MOVE)openSecField(p.k,p.f);+ return;+ }+ if(!p.on)return; // a click, not a drag+ stopDrag();+ const j=phaseIndexOfKey(p.k);+ if(commit&&j>=0&&p.to!==j&&p.to>=0&&p.to<phases.length&&reorderPhases(j,p.to)){+ renderSections();+ const r=rowAt(p.to); if(r){ r.classList.add('sec-flash'); try{ r.focus({preventScroll:true}); }catch(_){ r.focus(); } }+ }+ };+ document.addEventListener('pointerup',e=>end(e,true));+ document.addEventListener('pointercancel',e=>end(e,false));+ // A drag ends in a click on whatever is under it; it is not one.+ list.addEventListener('click',e=>{ if(list.classList.contains('sec-drag-on'))e.stopPropagation(); },true);+ list.addEventListener('keydown',e=>{+ const row=e.target.closest&&e.target.closest('.sec-row'); if(!row||row.parentElement!==list)return;+ const fld=e.target.closest('.sec-nm-t,.sec-ld-t');+ if(fld&&(e.key==='Enter'||e.key===' ')&&!e.altKey){ e.preventDefault(); e.stopPropagation(); openSecField(row.dataset.k,fld.dataset.f); return; }+ if(e.altKey&&!e.ctrlKey&&!e.metaKey&&(e.key==='ArrowUp'||e.key==='ArrowDown')&&!/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)){+ e.preventDefault(); e.stopPropagation();+ const i=+row.dataset.i, to=e.key==='ArrowUp'?i-1:i+1;+ if(to<0||to>=phases.length)return;+ if(reorderPhases(i,to)){ renderSections(); const r=rowAt(to); if(r)r.focus(); }+ }+ });+}+// Is a pointer pressed right now? A blur that a press caused waits for the release and its+// click before it commits, because committing repaints the list.+let ptrDown=false;+document.addEventListener('pointerdown',()=>{ ptrDown=true; },true);+document.addEventListener('pointerup',()=>{ ptrDown=false; },true);+document.addEventListener('pointercancel',()=>{ ptrDown=false; },true);+function afterPointer(fn){+ if(!ptrDown){ setTimeout(fn,0); return; }+ const f=()=>{ document.removeEventListener('pointerup',f); document.removeEventListener('pointercancel',f); setTimeout(fn,0); };+ document.addEventListener('pointerup',f); document.addEventListener('pointercancel',f);+}+// Close whatever row editor is open before another opens: an inline name or aside is+// COMMITTED (false when it refused its value and stays open, with its message), a Remove+// form or a palette is dropped, as before.+function settleSecEditors(){+ const open=$('setupView').querySelector('.sec-row.sec-editing');+ if(open&&open._secCommit)return open._secCommit()!==false;+ renderSections(); return true;+}+// Open a section's name or aside for editing, in place. Another row's open editor is+// closed first: an inline one by committing it (a refused value keeps it open and this+// does not open), a Remove form or a palette by dropping it, as before.+function openSecField(k,f){+ if(!settleSecEditors())return;+ const j=phaseIndexOfKey(k); if(j<0)return;+ openInline(rowAt(j),j,f);+}+function openInline(row,i,f,held){+ if(!row)return;+ const p=phases[i], k=phaseKey(p);+ const span=row.querySelector(f==='name'?'.sec-nm-t':'.sec-ld-t'); if(!span)return;+ row.classList.add('sec-editing','sec-inline');+ const inp=document.createElement('input'); inp.type='text';+ inp.className=f==='name'?'sec-nm':'sec-ld'; inp.maxLength=60; inp.autocomplete='off';+ inp.value=held?held.v:(f==='name'?p.name:(p.lead||''));+ inp.placeholder=f==='name'?'Section name':'an aside, e.g. the long pole';+ inp.setAttribute('aria-label',f==='name'?'Section name':'Aside beside the name');+ span.replaceWith(inp);+ // '' while open; 'done' once committed, 'cancel' on Escape, 'moved' when a repaint carried+ // the text over to a fresh box (that box takes over, and this one must do nothing more).+ let state='';+ const refuse=(t,keepFocus)=>{ inp.classList.add('bad'); secRowMsg(row,t); if(keepFocus)inp.focus(); return false; };+ const commit=keepFocus=>{+ if(state)return true;+ const j=phaseIndexOfKey(k);+ if(j<0){ state='done'; renderSections(true); return true; }+ const q=phases[j], v=inp.value.trim();+ if(f==='name'){+ if(!v)return refuse('A section needs a name.',keepFocus);+ if(v!==q.name&&secNameTaken(v,q))return refuse('There is already a section called "'+v+'". Pick a name no other section has.',keepFocus);+ }+ state='done';+ // Out of the editing state BEFORE the change repaints the list, so the repaint does not+ // carry this editor over and reopen it.+ row.classList.remove('sec-editing','sec-inline'); row._secHold=null; row._secCommit=null;+ if(f==='name'){ if(v!==q.name)renamePhase(j,v); }+ else setPhaseLead(j,v);+ renderSections(true);+ return true;+ };+ row._secCommit=()=>commit(false);+ row._secHold=()=>{ state='moved'; const foc=document.activeElement===inp;+ return {k,reopen:(r,j)=>{ openInline(r,j,f,{v:inp.value,focused:foc,sel:[inp.selectionStart,inp.selectionEnd],bad:inp.classList.contains('bad')});+ const m=row.querySelector('.sec-msg'); if(m)secRowMsg(r,m.textContent); }}; };+ inp.addEventListener('keydown',e=>{ e.stopPropagation();+ if(e.key==='Enter'){ e.preventDefault(); commit(true); }+ else if(e.key==='Escape'){ e.preventDefault(); state='cancel'; renderSections(); } });+ inp.addEventListener('input',()=>{ inp.classList.remove('bad'); const m=row.querySelector('.sec-msg'); if(m)m.remove(); });+ // Clicking away commits, after the click has landed (afterPointer), so the repaint the+ // commit causes cannot take the clicked control out from under its own click. A box a+ // repaint took out of the page, or that Escape cancelled, does nothing.+ inp.addEventListener('blur',()=>{ afterPointer(()=>{ if(!state&&inp.isConnected&&document.activeElement!==inp)commit(false); }); });+ if(held){+ if(held.bad)inp.classList.add('bad');+ if(held.focused){ inp.focus(); try{ inp.setSelectionRange(held.sel[0],held.sel[1]); }catch(_){} }+ } else { inp.focus(); inp.select(); }+} // Rebuild the list from phases[]. With `keep`, an open row editor survives the rebuild: // it is found again by its section's phaseKey (NOT by its index, which an undo, a redo // or another tab's reorder can change under it), reopened on that section's new row // with whatever had been typed into it, and dropped only if the section itself is gone.-// Every passive refresh keeps; a deliberate close (Save, Cancel, Escape) does not.+// Every passive refresh keeps; a deliberate close (a commit, Cancel, Escape) does not. // // Until 0.58.0's review the editor was left alone instead, stale, and its buttons still // carried the index they were opened with, so Ctrl+Z with a Remove open, followed by the@@ -6668,8 +7041,12 @@ function wireSetup(){ function renderSections(keep){ const v=$('setupView'); if(!v)return; const list=v.querySelector('.sec-list');+ // Mid-drag the list is not repainted under the pointer; the drop repaints it.+ if(list.classList.contains('sec-drag-on'))return; let held=null; if(keep){ const ed=list.querySelector('.sec-row.sec-editing'); if(ed&&ed._secHold)held=ed._secHold(); }+ // Any inline editor not carried over is finished with: its blur must not commit later.+ list.querySelectorAll('.sec-row.sec-editing').forEach(r=>{ if(r._secHold&&!held)try{ r._secHold(); }catch(_){} }); // 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);@@ -6678,37 +7055,35 @@ function renderSections(keep){ phases.forEach((p,i)=>{ const n=laneCount(i), sp=laneSpan(i); const row=document.createElement('div'); row.className='sec-row'; row.dataset.i=String(i);- row.setAttribute('role','listitem');- row.innerHTML=`<button class="sec-sw" title="Colour" style="background:${esc(phaseColor(i))}"></button>`+- `<span class="sec-name" title="${esc(p.name)}${p.lead?' · '+esc(p.lead):''}">${esc(p.name)}${p.lead?`<span class="sec-lead">${esc(p.lead)}</span>`:''}</span>`++ row.setAttribute('role','listitem'); row.tabIndex=0;+ row.title='Drag to move this section, or Alt+Up / Alt+Down while the row has the focus. Click the name or the aside to edit it.';+ row.innerHTML=`<span class="sec-grip" aria-hidden="true"><svg viewBox="0 0 8 14" width="8" height="14"><g fill="currentColor"><circle cx="2" cy="2" r="1.2"/><circle cx="6" cy="2" r="1.2"/><circle cx="2" cy="7" r="1.2"/><circle cx="6" cy="7" r="1.2"/><circle cx="2" cy="12" r="1.2"/><circle cx="6" cy="12" r="1.2"/></g></svg></span>`++ `<button class="sec-sw" title="Colour" style="background:${esc(phaseColor(i))}"></button>`++ `<span class="sec-name"><span class="sec-nm-t" role="button" tabindex="0" data-f="name" title="${esc(p.name)}. Click to rename">${esc(p.name)}</span>`++ `<span class="sec-ld-t${p.lead?'':' empty'}" role="button" tabindex="0" data-f="lead" title="${p.lead?esc(p.lead)+'. Click to edit the aside':'Click to add an aside beside the name'}">${p.lead?esc(p.lead):'add an aside'}</span></span>`+ `<span class="sec-count">${n} row${n===1?'':'s'}</span>`+ (sp.from?`<span class="sec-dates">${esc(fmt(sp.from))}${sp.to!==sp.from?' to '+esc(fmt(sp.to)):''}</span>` :`<span class="sec-dates none">no dates</span>`)+ `<span class="sec-acts">`+- `<button class="sec-btn" data-a="up" title="Move up"${i===0?' disabled':''}>↑</button>`+- `<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>`+secTagsHtml(p); row.dataset.k=phaseKey(p); list.appendChild(row); }); list.querySelectorAll('.sec-row').forEach(row=>{- const i=+row.dataset.i;+ const k=row.dataset.k; // The swatch toggles its own palette: a second click on it closes it again. row.querySelector('.sec-sw').addEventListener('click',()=>{ const was=row.classList.contains('sec-editing')&&row.querySelector('.sec-pal');- renderSections(); if(!was)openSwatch(rowAt(i),i); });+ if(!settleSecEditors())return;+ const i=phaseIndexOfKey(k); if(!was&&i>=0)openSwatch(rowAt(i),i); });+ // One row editor at a time: opening a second one closes the first, and what was+ // typed into the first is dropped, the way clicking into another table cell ends+ // the one before. Two open forms on one page is two half-finished edits and no+ // way to tell which Enter saves. Switching lens, by contrast, keeps it. row.querySelectorAll('.sec-btn').forEach(b=>b.addEventListener('click',()=>{- const a=b.dataset.a;- if(a==='up'&&i>0){ reorderPhases(i,i-1); renderSections(); keepRowFocus(i-1,'up'); }- else if(a==='down'&&i<phases.length-1){ reorderPhases(i,i+1); renderSections(); keepRowFocus(i+1,'down'); }- // One row editor at a time: opening a second one closes the first, and what was- // typed into the first is dropped, the way clicking into another table cell ends- // the one before. Two open forms on one page is two half-finished edits and no- // way to tell which Enter saves. Switching lens, by contrast, keeps it.- else if(a==='edit'){ renderSections(); openEdit(rowAt(i),i); }- else if(a==='del'){ renderSections(); openDelete(rowAt(i),i); }+ if(b.dataset.a!=='del'||!settleSecEditors())return;+ const i=phaseIndexOfKey(k); if(i>=0)openDelete(rowAt(i),i); })); }); list.querySelectorAll('.sec-row').forEach(wireSecTags);@@ -6759,9 +7134,16 @@ function editTags(change,refilter,opt){ r.tags=t; refreshRowTags(r); });- if(!before.length&&!rowsBefore.length)return 0;+ // The registry (0.61.0): a rename or a remove everywhere takes the tag's registry entry+ // with it, in the same undo entry, so its colour follows a rename and an undo of a remove+ // brings the colour back too.+ const regBefore=opt.reg?regSnap():null, regSig0=opt.reg?regSig():'';+ if(opt.reg)opt.reg();+ const regMoved=!!opt.reg&®Sig()!==regSig0;+ if(!before.length&&!rowsBefore.length&&!regMoved)return 0; const sn={type:'phase.tags',list:before}; if(rowsBefore.length)sn.rows=rowsBefore;+ if(regMoved){ sn.reg=regBefore; regInSidecar=true; } if(refilter){ const nf=refilter(prefs.tagFilter); if(JSON.stringify(nf)!==JSON.stringify(prefs.tagFilter)){ sn.filter=prefs.tagFilter.slice(); prefs.tagFilter=nf; savePrefs(); }@@ -6769,7 +7151,7 @@ function editTags(change,refilter,opt){ pushUndo(sn); afterTagsChange(); scheduleSave(opt.via||'setup');- return before.length+rowsBefore.length;+ return before.length+rowsBefore.length+(regMoved?1:0); } function addPhaseTags(k,words){ const j=phaseIndexOfKey(k); if(j<0)return 0;@@ -6789,12 +7171,51 @@ function renameTagEverywhere(from,to){ const ren=list=>list.some(t=>tagKey(t)===k)?list.map(t=>tagKey(t)===k?to:t):null; return editTags(p=>ren(p.tags||[]), 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; },- {rows:r=>ren(ownTags(r))});+ {rows:r=>ren(ownTags(r)),reg:()=>{+ // The entry is renamed in place (same `_k`, so the changelog says "renamed"). Onto a+ // name the registry already lists, the two merge: the target keeps its own colour,+ // or takes this one's if it has none.+ const e=regFind(from); if(!e)return;+ const o=regFind(to);+ if(o&&o!==e){ if(!o.color&&e.color)o.color=e.color; tagReg.splice(tagReg.indexOf(e),1); }+ else e.name=to;+ }}); } function removeTagEverywhere(tag){ const k=tagKey(tag); const rm=list=>list.some(t=>tagKey(t)===k)?list.filter(t=>tagKey(t)!==k):null;- return editTags(p=>rm(p.tags||[]),null,{rows:r=>rm(ownTags(r))});+ return editTags(p=>rm(p.tags||[]),null,{rows:r=>rm(ownTags(r)),reg:()=>{ tagReg=tagReg.filter(e=>tagKey(e.name)!==k); }});+}+// The registry's own edits (0.61.0): a new tag, and a colour. One gesture, one undo entry+// ('tag.reg', the registry as it was), one save; the changelog lines come from the diff.+function editReg(fn,via){+ const before=regSnap(), sig=regSig();+ fn();+ if(regSig()===sig)return false;+ regInSidecar=true;+ pushUndo({type:'tag.reg',reg:before});+ afterTagsChange();+ scheduleSave(via||'setup');+ return true;+}+// '' when it was made, or why not: 'empty', or 'taken' (the plan already has that tag,+// carried or listed, in any case).+function createTag(name){+ const t=(normalizeTags([String(name||'')])||[])[0];+ if(!t)return 'empty';+ if(planTags().some(e=>e.key===tagKey(t)))return 'taken';+ editReg(()=>{ tagReg.push(regEntry({name:t})); });+ return '';+}+// A colour for a tag, or '' for none. A carried tag the registry does not list yet gets an+// entry in the plan's own spelling.+function setTagColor(tag,color){+ color=normTagColor(color||'');+ return editReg(()=>{+ let e=regFind(tag);+ if(!e){ if(!color)return; const sp=(planTags().find(x=>x.key===tagKey(tag))||{tag}).tag; e=regEntry({name:sp}); tagReg.push(e); }+ if(color)e.color=color; else delete e.color;+ }); } // "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@@ -6817,7 +7238,7 @@ function tagsFromPrefixes(){ } 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('')++ return `<div class="sec-tags">`+t.map(x=>`<span class="sec-tag"${tagAttrs(x)}>${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@@ -6870,9 +7291,41 @@ function restoreTagInput(list,held){ 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+// The Tags card: every tag in the plan, how many sections and rows carry it, its colour,+// and the plan-wide edits: click a name to rename it everywhere, Remove to take it off+// everything. "New tag" adds one nothing carries yet (0.61.0). The name-prefix button+// sits on top when it has work to do.+//+// The palette (0.61.0): the three brand colours first, then six chosen to sit with them+// and with the section colours, light enough to read on the dark theme and each walked to+// a readable ink on paper by tagPaint. A hex box for anything else, and No colour.+const TAG_PALETTE=[['#00b8b1','Teal'],['#8c6bf7','Purple'],['#64abff','Blue'],+ ['#ff8a7a','Coral'],['#e8b64a','Amber'],['#5fd7a3','Mint'],['#ec7ab8','Rose'],['#6f7cff','Indigo'],['#8b9bb4','Slate']];+let tagEditing=null; // the key of the tag whose name is being edited+let tagPalOpen=null; // the key of the tag whose palette is open+// An open rename box on another tag is committed first, as a click away from it would.+function settleTagRename(){ const nb=$('suTags').querySelector('.tag-nm'); if(nb&&nb._go)nb._go(); }+function openTagRename(k){+ if(tagEditing===k)return;+ settleTagRename();+ tagPalOpen=null; tagEditing=k; renderTagsCard();+ const i=$('suTags').querySelector('.tag-nm'); if(i){ i.focus(); i.select(); }+}+// A click on a tag's name opens its rename box, from the pointerdown's record, so a blur+// commit in another box repainting the card between press and release cannot eat it.+function wireTagList(list){+ let press=null;+ list.addEventListener('pointerdown',e=>{+ if(e.button!==0)return;+ const t=e.target.closest('.tag-nm-t'), row=t&&t.closest('.tag-row');+ press=row?{k:row.dataset.tag,x:e.clientX,y:e.clientY,id:e.pointerId}:null;+ });+ document.addEventListener('pointerup',e=>{+ if(!press||e.pointerId!==press.id)return;+ const p=press; press=null;+ if(Math.hypot(e.clientX-p.x,e.clientY-p.y)<5)openTagRename(p.k);+ });+} function renderTagsCard(){ const card=$('suTags'); if(!card)return; const tags=planTags();@@ -6887,51 +7340,68 @@ function renderTagsCard(){ 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;+ // What was typed into an open rename or hex box survives the repaint, with the focus.+ const nmBox=list.querySelector('.tag-nm'), hexBox=list.querySelector('.tag-hex');+ const keyOf=el=>{ const r=el&&el.closest('.tag-row'); return r?r.dataset.tag:''; };+ const draft=nmBox&&keyOf(nmBox)===tagEditing?{v:nmBox.value,f:document.activeElement===nmBox}:null;+ const hexDraft=hexBox&&keyOf(hexBox)===tagPalOpen?{v:hexBox.value,f:document.activeElement===hexBox,msg:(list.querySelector('.tag-pal .sec-msg')||{}).textContent||''}:null;+ list.querySelectorAll('.tag-nm').forEach(i=>{ i._moved=true; }); 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, or give a row tags in its editor or the table's Tags column${cand.length?', or tag them all from their name prefixes with the button above':''}.</div>`;+ if(tagPalOpen&&!tags.some(e=>e.key===tagPalOpen))tagPalOpen=null;+ list.innerHTML=tags.length?'':`<div class="tag-empty">No tags yet. Make one with New tag above, type one into the box under any section below, or give a row tags in its editor or the table's Tags column${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); const rowNames=e.rows?items.filter(r=>!isGroup(r)&&ownTags(r).some(t=>tagKey(t)===e.key)).map(r=>r.name||''):[];- const tip=[secs.length?'Sections: '+secs.join(', '):'',rowNames.length?'Rows: '+rowNames.slice(0,12).join(', ')+(rowNames.length>12?', ...':''):''].filter(Boolean).join('\n');- row.innerHTML=`<span class="tag-name"><span class="sec-tag">${esc(e.tag)}</span></span>`+- `<span class="tag-n" title="${esc(tip)}">${esc(tagCountText(e))}</span>`+- `<span class="tag-acts"><button type="button" class="tag-btn" data-a="rename" title="Rename this tag on every section and row that carries it">Rename</button>`+- `<button type="button" class="tag-btn danger" data-a="del" title="Remove this tag from ${esc(tagCountText(e))}. One Ctrl+Z puts it back.">Remove</button></span>`;+ const tip=[secs.length?'Sections: '+secs.join(', '):'',rowNames.length?'Rows: '+rowNames.slice(0,12).join(', ')+(rowNames.length>12?', ...':''):''].filter(Boolean).join('\n')||'Nothing carries this tag yet. Add it to a section below, or to a row in its editor or the Table.';+ row.innerHTML=`<button type="button" class="tag-sw${e.color?'':' none'}" title="${e.color?'Colour '+e.color+'. Click to change it':'No colour. Click to give this tag one'}" aria-label="Colour of the tag ${esc(e.tag)}"${e.color?` style="background:${e.color}"`:''}></button>`++ `<span class="tag-name"><span class="sec-tag tag-nm-t" role="button" tabindex="0" title="Click to rename ${esc(e.tag)} on every section and row that carries it"${tagAttrs(e.tag)}>${esc(e.tag)}</span></span>`++ `<span class="tag-n${e.used?'':' unused'}" title="${esc(tip)}">${esc(tagCountText(e))}</span>`++ `<span class="tag-acts"><button type="button" class="tag-btn danger" data-a="del" title="${e.used?'Remove this tag from '+esc(tagCountText(e))+'. One Ctrl+Z puts it back.':'Remove this tag from the plan. 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(); } } });+ row.querySelector('[data-a="del"]').addEventListener('click',()=>{ tagEditing=null; tagPalOpen=null; removeTagEverywhere(e.tag); });+ row.querySelector('.tag-sw').addEventListener('click',()=>{ settleTagRename(); tagEditing=null; tagPalOpen=tagPalOpen===e.key?null:e.key; renderTagsCard();+ const h=list.querySelector('.tag-hex'); if(h)h.focus(); });+ row.querySelector('.tag-nm-t').addEventListener('keydown',ev=>{ if(ev.key==='Enter'||ev.key===' '){ ev.preventDefault(); ev.stopPropagation(); openTagRename(e.key); } }); 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(); });+ const nm=document.createElement('input'); nm.type='text'; nm.className='tag-nm'; nm.maxLength=TAG_MAX_LEN; nm.autocomplete='off';+ nm.value=draft?draft.v:e.tag; nm.setAttribute('aria-label','New name for the tag '+e.tag);+ row.querySelector('.tag-name').replaceChildren(nm);+ let done=false;+ const go=()=>{ if(done||nm._moved)return; done=true; const v=nm.value.trim(); tagEditing=null;+ if(!(v&&v!==e.tag&&renameTagEverywhere(e.tag,v)))renderTagsCard(); };+ nm._go=go; 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(); }+ else if(ev.key==='Escape'){ ev.preventDefault(); done=true; tagEditing=null; renderTagsCard(); } });+ nm.addEventListener('blur',()=>{ afterPointer(()=>{ if(!done&&!nm._moved&&nm.isConnected&&document.activeElement!==nm)go(); }); });+ if(draft&&draft.f)nm.focus();+ }+ if(tagPalOpen===e.key){+ row.classList.add('tag-editing');+ const w=document.createElement('div'); w.className='tag-pal';+ w.innerHTML=TAG_PALETTE.map(([hex,nm],i)=>(i===3?'<span class="tag-pal-sep" aria-hidden="true"></span>':'')++ `<button type="button" class="tag-pc${e.color===hex?' on':''}" data-hex="${hex}" title="${nm} ${hex}${i<3?', a brand colour':''}" aria-label="${nm}" style="background:${hex}"></button>`).join('')++ `<input type="text" class="tag-hex" placeholder="#rrggbb" maxlength="7" value="${esc(hexDraft?hexDraft.v:(e.color||''))}" aria-label="Colour of ${esc(e.tag)} as #rrggbb">`++ `<button type="button" class="tag-btn" data-a="nocolor" title="Take the colour off this tag"${e.color?'':' disabled'}>No colour</button>`;+ row.appendChild(w);+ const apply=c=>{ tagPalOpen=null; if(!setTagColor(e.tag,c))renderTagsCard(); };+ w.querySelectorAll('.tag-pc').forEach(b=>b.addEventListener('click',()=>apply(b.dataset.hex)));+ w.querySelector('[data-a="nocolor"]').addEventListener('click',()=>apply(''));+ const hx=w.querySelector('.tag-hex');+ hx.addEventListener('keydown',ev=>{ ev.stopPropagation();+ if(ev.key==='Enter'){ ev.preventDefault(); const nv=normHex(hx.value);+ if(nv===null){ hx.classList.add('bad'); secRowMsg(w,'"'+hx.value.trim()+'" is not a colour. Type six hex digits, like #e8b64a, or three, like #eb4.'); return; }+ apply(nv); }+ else if(ev.key==='Escape'){ ev.preventDefault(); tagPalOpen=null; renderTagsCard(); } });+ if(hexDraft){ if(hexDraft.msg){ hx.classList.add('bad'); secRowMsg(w,hexDraft.msg); } if(hexDraft.f)hx.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); }-// Moving a section rebuilds the list, which would drop the keyboard focus on the floor.-// It lands on the same arrow in the section's NEW row, so pressing Enter again keeps-// moving the same section, and falls back to the other arrow at either end.-function keepRowFocus(i,a){- const row=rowAt(i); if(!row)return;- let b=row.querySelector(`.sec-btn[data-a="${a}"]`);- if(b&&b.disabled)b=row.querySelector(`.sec-btn[data-a="${a==='up'?'down':'up'}"]`);- if(b)b.focus();-} // An inline message under a row editor, for the refusals (a taken name, a bad colour). function secRowMsg(wrap,t){ let m=wrap.querySelector('.sec-msg');@@ -6960,38 +7430,11 @@ function openSwatch(row,i,held){ else if(e.key==='Escape'){ renderSections(); } }); if(!held)hex.focus(); }-function openEdit(row,i,held){- row.classList.add('sec-editing');- const p=phases[i], k=phaseKey(p);- const wrap=document.createElement('div');- wrap.style.cssText='display:flex;flex-wrap:wrap;gap:6px;width:100%;order:9;padding:6px 0 0 25px';- wrap.innerHTML=`<input type="text" class="sec-nm" value="${esc(held?held.nm:p.name)}" maxlength="60" style="flex:1 1 180px;min-width:0" aria-label="Section name">`+- `<input type="text" class="sec-ld" value="${esc(held?held.ld:(p.lead||''))}" maxlength="60" placeholder="aside, e.g. the long pole (Adonis)" style="flex:1 1 180px;min-width:0" aria-label="Aside">`+- `<button class="de-btn primary sec-ok" style="padding:2px 10px">Save</button>`;- row.appendChild(wrap);- const nm=wrap.querySelector('.sec-nm'), ld=wrap.querySelector('.sec-ld');- row._secHold=()=>({k,reopen:(r,j)=>openEdit(r,j,{nm:nm.value,ld:ld.value})});- const save=()=>{- const j=phaseIndexOfKey(k); if(j<0){ renderSections(); return; }- const q=phases[j], v=nm.value.trim(), lead=ld.value.trim();- if(v&&v!==q.name&&secNameTaken(v,q)){- nm.style.borderColor='#f85149';- secRowMsg(wrap,'There is already a section called "'+v+'". Pick a name no other section has.');- nm.focus(); return;- }- withOneUndo(()=>{ if(v&&v!==q.name)renamePhase(j,v); setPhaseLead(j,lead); });- renderSections();- };- wrap.querySelector('.sec-ok').addEventListener('click',save);- [nm,ld].forEach(el=>el.addEventListener('keydown',e=>{ e.stopPropagation();- if(e.key==='Enter')save(); else if(e.key==='Escape')renderSections(); }));- if(!held){ nm.focus(); nm.select(); }-} function openDelete(row,i,held){ const n=laneCount(i), k=phaseKey(phases[i]); row.classList.add('sec-editing'); const wrap=document.createElement('div');- wrap.style.cssText='display:flex;gap:6px;width:100%;order:9;padding:6px 0 0 25px;align-items:center;flex-wrap:wrap';+ wrap.style.cssText='display:flex;gap:6px;width:100%;order:9;padding:6px 0 0 var(--sec-indent);align-items:center;flex-wrap:wrap'; if(!n){ wrap.innerHTML=`<span style="font-size:11px;color:var(--text2);flex:1">Remove "${esc(phases[i].name)}"? It has no rows.</span>`+ `<button class="de-btn primary sec-go" style="padding:2px 10px">Remove</button>`+@@ -7158,7 +7601,7 @@ function renderBatchTags(){ rows.forEach(r=>ownTags(r).forEach(t=>{ const k=tagKey(t); if(!m.has(k))m.set(k,{t,n:0}); m.get(k).n++; })); w.innerHTML=`<div class="batch-row bt-row"><label>tags</label><input type="text" id="bTag" list="tagList" maxlength="${TAG_MAX_LEN}" autocomplete="off" placeholder="a tag" aria-label="A tag to add to every selected row">` +`<button type="button" class="de-btn secondary" id="bTagAdd" title="Add this tag to all ${plural(rows.length,'selected row')}, milestones included. One Ctrl+Z takes it back.">Add to all</button></div>`- +(m.size?`<div class="bt-chips">`+[...m.values()].map(e=>`<span class="sec-tag" title="${esc(e.t)}: on ${e.n} of the ${rows.length} selected">${esc(e.t)} <small>${e.n}</small><button type="button" class="bt-x" data-tag="${esc(e.t)}" title="Remove ${esc(e.t)} from every selected row that carries it" aria-label="Remove ${esc(e.t)} from every selected row">×</button></span>`).join('')+'</div>'+ +(m.size?`<div class="bt-chips">`+[...m.values()].map(e=>`<span class="sec-tag"${tagAttrs(e.t)} title="${esc(e.t)}: on ${e.n} of the ${rows.length} selected">${esc(e.t)} <small>${e.n}</small><button type="button" class="bt-x" data-tag="${esc(e.t)}" title="Remove ${esc(e.t)} from every selected row that carries it" aria-label="Remove ${esc(e.t)} from every selected row">×</button></span>`).join('')+'</div>' :`<div class="bt-none">No selected row carries a tag of its own.</div>`); const inp=$('bTag'); const add=()=>{ const words=inp.value.split(',').map(x=>x.trim()).filter(Boolean); if(!words.length){ inp.focus(); return; }@@ -7997,7 +8440,9 @@ function clientMergedData(){ 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; if(p.tags&&p.tags.length)o.tags=p.tags.slice(); return o;});- return {project:Object.assign({},project),phases:phs,+ const pj=Object.assign({},project); delete pj.tags;+ if(tagReg.length)pj.tags=regOut(); // the registry, or no key (0.61.0)+ return {project:pj,phases:phs, items:items.filter(r=>!isDerivedGroup(r)).map(clean)}; } // Byte-for-byte the server's itemsToCSV, because a CSV that round-trips has to@@ -8876,6 +9321,8 @@ $('btnTheme').addEventListener('click',()=>{ // the element is a resolved rgb(), so nothing recomputes it on its own. The memo is // keyed by theme, so this is a recompute and not a stale read. recolorDepArrows();+ // and a tag chip's ink is measured against the page the same way (0.61.0)+ repaintTagInks(); }); syncThemeBtn(); }@@ -8931,7 +9378,7 @@ function wireTagBox(box,initial,kind,onChange){ let list=(normalizeTags(initial)||[]).slice(); const chips=box.querySelector('.tb-chips'), inp=box.querySelector('.tb-in'); const paint=()=>{- chips.innerHTML=list.map(t=>`<span class="sec-tag">${esc(t)}<button type="button" class="tb-x" data-tb="${kind}" data-tag="${esc(t)}" tabindex="-1" title="Remove the tag ${esc(t)}" aria-label="Remove the tag ${esc(t)}">×</button></span>`).join('');+ chips.innerHTML=list.map(t=>`<span class="sec-tag"${tagAttrs(t)}>${esc(t)}<button type="button" class="tb-x" data-tb="${kind}" data-tag="${esc(t)}" tabindex="-1" title="Remove the tag ${esc(t)}" aria-label="Remove the tag ${esc(t)}">×</button></span>`).join(''); const full=list.length>=TAG_MAX_COUNT; inp.placeholder=full?'12 tags, the most a row can carry':list.length?'+ tag':'+ add a tag, Enter'; inp.readOnly=full;@@ -9944,8 +10391,8 @@ function tagsCell(r,ed){ const own=ownTags(r), ok=new Set(own.map(tagKey)), sec=phaseTagsOf(r).filter(t=>!ok.has(tagKey(t))); const tip=[own.length?'Own: '+own.join(', '):'',sec.length?'From its section: '+sec.join(', '):''].filter(Boolean).join('. '); return `<td class="c-tags${ed}${own.length||sec.length?'':' empty'}" data-field="tags"${tip?` title="${esc(tip)}"`:''}><span class="tags-trunc">`- +own.map(t=>`<span class="row-tag">${esc(t)}</span>`).join('')- +sec.map(t=>`<span class="row-tag inh">${esc(t)}</span>`).join('')+'</span></td>';+ +own.map(t=>`<span class="row-tag"${tagAttrs(t)}>${esc(t)}</span>`).join('')+ +sec.map(t=>`<span class="row-tag inh"${tagAttrs(t)}>${esc(t)}</span>`).join('')+'</span></td>'; } function renderTable(){ if(!tableBody)return;@@ -11177,7 +11624,7 @@ function analyticsModel(){ // counts under its own tags and its section's. A row with two tags counts under both, so // the shares can add up to more than 100%; the rows with no tag at all are one line of // their own, so what nobody has classified is as visible as what somebody has.- const tagRows=planTags().map(e=>({tag:e.tag,key:e.key,items:0,tasks:0,ms:0,hours:0}));+ const tagRows=usedTags().map(e=>({tag:e.tag,key:e.key,items:0,tasks:0,ms:0,hours:0})); const tagIdx=new Map(tagRows.map(t=>[t.key,t])); const untagged={items:0,tasks:0,ms:0,hours:0}; rows.forEach(r=>{@@ -11294,7 +11741,7 @@ function renderAnalytics(){ +'<td style="width:24%">'+anBar(pct(b.hours,m.totalHours))+'</td>' +'<td class="n">'+b.tasks+(b.ms?' +'+b.ms+'M':'')+'</td><td class="n">'+esc(hrs(b.hours))+'</td>' +'<td class="n muted">'+pct(b.hours,m.totalHours)+'%</td></tr>';- const tr=m.tagRows.map(t=>trow('<span class="sec-tag an-tag">'+esc(t.tag)+'</span>',t)).join('')+ const tr=m.tagRows.map(t=>trow('<span class="sec-tag an-tag"'+tagAttrs(t.tag)+'>'+esc(t.tag)+'</span>',t)).join('') +trow('no tag',m.untagged,true); out.push(card('wide','Per tag','Every tag in the plan, counted the way the legend filters: a row carries its own tags and its section\'s. A row with two tags counts under both, so the shares can add up to more than 100%. Cancelled work counts as an item and not in the hours.', '<table class="an-table" id="anTags"><thead><tr><th>Tag</th><th>Share of hours</th><th class="n">Items</th><th class="n">Hours</th><th class="n">%</th></tr></thead><tbody>'+tr+'</tbody></table>'));
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.60.0",+ "version": "0.61.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.60.0",+ "version": "0.61.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+67@@ -687,6 +687,22 @@ function describeEvent(e) { 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}`; }+ // The tag registry (0.61.0): project.tags, the plan's list of tags with their colours+ // and the tags nothing carries yet. Section and row tag edits keep their own lines above.+ if (action === 'tag.create' || action === 'tag.color' || action === 'tag.delete' || action === 'tag.rename') {+ const str = v => (typeof v === 'string' ? oneLine(v) : '');+ const tag = str(e.tag) || str(fld('to', 'name', '')) || '?';+ if (action === 'tag.rename') return `${who} renamed the tag "${str(fld('from', 'name', '')) || '?'}" to "${str(fld('to', 'name', '')) || '?'}"${via}`;+ if (action === 'tag.create') {+ const c = str(e.color) || str(fld('to', 'color', ''));+ return `${who} created the tag "${tag}"${c ? ` in ${c}` : ''}${via}`;+ }+ if (action === 'tag.delete') return `${who} removed the tag "${tag}" from the plan's tag list${via}`;+ const f = str(fld('from', 'color', '')), t = str(fld('to', 'color', ''));+ if (!t) return `${who} cleared the colour on the tag "${tag}"${f ? ` (was ${f})` : ''}${via}`;+ if (!f) return `${who} coloured the tag "${tag}" ${t}${via}`;+ return `${who} recoloured the tag "${tag}" ${f} -> ${t}${via}`;+ } if (action === 'rename') { return `${who} renamed "${fld('from', 'name', '?')}" to "${fld('to', 'name', '?')}"${via}`; }@@ -901,6 +917,47 @@ function normalizeTags(v) { } return out; }+// The tag registry (0.61.0): project.tags, a list of {name, color?}. It is where a tag+// keeps its colour, and where a tag nothing carries yet lives. A tag on a section or a+// row that the registry does not list is still a tag; the registry adds to that, it does+// not gate it. Names follow normalizeTags (trim, `;` to a space, 32 characters, unique+// without regard to case, first spelling wins); a colour is kept only as lowercase+// #rrggbb (#abc is widened), anything else is dropped and the entry stays. Capped at+// TAG_REG_MAX entries. Array in, array out (possibly empty); anything else, undefined.+const TAG_REG_MAX = 200;+function normTagColor(v) {+ if (typeof v !== 'string') return '';+ let c = v.trim().toLowerCase();+ if (!c) return '';+ if (c[0] !== '#') c = '#' + c;+ if (/^#[0-9a-f]{3}$/.test(c)) c = '#' + c[1] + c[1] + c[2] + c[2] + c[3] + c[3];+ return /^#[0-9a-f]{6}$/.test(c) ? c : '';+}+function normalizeTagRegistry(v) {+ if (!Array.isArray(v)) return undefined;+ const out = [], seen = new Set();+ for (const e of v) {+ const raw = typeof e === 'string' ? e : (e && typeof e === 'object' ? e.name : undefined);+ if (typeof raw !== 'string') continue;+ const s = raw.replace(/;/g, ' ').trim().slice(0, TAG_MAX_LEN).trim();+ if (!s || seen.has(s.toLowerCase())) continue;+ seen.add(s.toLowerCase());+ const o = { name: s };+ const c = e && typeof e === 'object' ? normTagColor(e.color) : '';+ if (c) o.color = c;+ out.push(o);+ if (out.length >= TAG_REG_MAX) break;+ }+ return out;+}+// Data-file rule for the registry, as for a tag list: a non-empty list or no key, so a+// plan that never used it carries no `project.tags` at all.+function tidyTagRegistry(proj) {+ if (!proj || typeof proj !== 'object' || !('tags' in proj)) return proj;+ const t = normalizeTagRegistry(proj.tags);+ if (t && t.length) proj.tags = t; else delete proj.tags;+ return proj;+} // Data-file rule: a non-empty list or no key at all. Touches nothing when the // object never had the key, so an untagged plan is not rewritten in any way. function tidyTags(o) {@@ -913,6 +970,7 @@ function tidyPlanTags(data) { if (!data || typeof data !== 'object') return data; if (Array.isArray(data.phases)) data.phases.forEach(tidyTags); if (Array.isArray(data.items)) data.items.forEach(tidyTags);+ tidyTagRegistry(data.project); return data; } // Sidecar rule: an array (normalised, [] kept as "cleared") or no key. Applies to@@ -927,6 +985,8 @@ function tidyStateTags(st) { if (Array.isArray(st.items)) st.items.forEach(side); if (Array.isArray(st.phases)) st.phases.forEach(side); if (Array.isArray(st.added)) st.added.forEach(tidyTags);+ // The sidecar's registry: absent means the data file's stands, [] means it was emptied.+ if ('tags' in st) { const t = normalizeTagRegistry(st.tags); if (t) st.tags = t; else delete st.tags; } return st; } @@ -1452,6 +1512,13 @@ function mergedData() { } }); }+ // The tag registry (0.61.0). Absent: the data file's stands. [] : emptied, so the merged+ // plan carries no project.tags key, exactly like a plan that never had one.+ if (Array.isArray(state.tags)) {+ if (!data.project || typeof data.project !== 'object') data.project = {};+ if (state.tags.length) data.project.tags = state.tags.map(t => Object.assign({}, t));+ else delete data.project.tags;+ } if (Array.isArray(state.order) && state.order.length === data.items.length) { const reordered = state.order.map(u => byUid.get(u)).filter(r => r && data.items.includes(r)); // All or nothing. A single unresolved uid would otherwise drop a row.
test/server_test.mjs+71−1@@ -1,5 +1,5 @@ #!/usr/bin/env node-// Server-only tests for tags (0.59/0.60): no browser. Spawns server.js --no-auth on+// Server-only tests for tags (0.59/0.60) and the tag registry (0.61): no browser. Spawns server.js --no-auth on // ports 8940-8949 against temp copies of a small plan and exercises the contract // over HTTP. Exits non-zero on any failure. //@@ -309,6 +309,76 @@ async function main() { ex = await getJ(C, '/export/json'); ok(byId(ex, 'etch').phase === 0 && byId(ex, 'ship').phase === 1, 'a phase index outside the sidecar\'s list is ignored', ex.items.map(x => [x.name, x.phase])); stop(C);++ // ── D: the tag registry, project.tags (0.61.0) ──+ console.log('D. the tag registry');+ // Absent stays absent: an old data file and an old sidecar load as before, with no key.+ const D = await start('d', basePlan(true), null, 8940);+ ex = await getJ(D, '/export/json');+ ok(!('tags' in ex.project), 'a plan that never used the registry exports no project.tags');+ await post(D, '/save', { v: 4, items: [], phases: [], order: [], deleted: [], added: [] });+ ex = await getJ(D, '/export/json');+ ok(!('tags' in ex.project), 'a sidecar with no tags key leaves the registry absent');+ // Normalisation through /save: names by the normalizeTags rules, colours to lowercase+ // #rrggbb, junk dropped, unique without regard to case (first spelling wins).+ r = await post(D, '/save', { v: 4, items: [], phases: [], order: [], deleted: [], added: [],+ tags: [{ name: ' Tech ', color: '#8C6BF7' }, { name: 'tech', color: '#000000' }, { name: 'Ops', color: 'E8B' }, { name: 'x;y', color: 'blue' },+ 'bare', { name: 'z'.repeat(40) }, { name: '' }, null, 7, { color: '#ffffff' }, { name: 'Gate', color: '#12345' }] });+ ex = await getJ(D, '/export/json');+ const wantReg = [{ name: 'Tech', color: '#8c6bf7' }, { name: 'Ops', color: '#ee88bb' }, { name: 'x y' }, { name: 'bare' }, { name: 'z'.repeat(32) }, { name: 'Gate' }];+ ok(r.status === 200 && eq(ex.project.tags, wantReg), 'the sidecar registry is normalised and merged into project.tags', ex.project.tags);+ const rawD = JSON.parse(fs.readFileSync(path.join(D.dir, 'plan-state.json'), 'utf8'));+ ok(eq(rawD.tags, wantReg), 'the sidecar file on disk holds the normalised registry', rawD.tags);+ ok(eq(ex.phases[0].tags, ['Tech']) && eq(byId(ex, 'etch').tags, ['long-lead']), 'section and row tags are untouched by the registry');+ const htmlD = await getT(D, '/export/html');+ ok(htmlD.includes('"color":"#8c6bf7"'), 'the HTML snapshot carries project.tags');+ // [] empties it: the merged plan has no key, as if it had never had one.+ await post(D, '/save', { v: 4, items: [], phases: [], order: [], deleted: [], added: [], tags: [] });+ ex = await getJ(D, '/export/json');+ ok(!('tags' in ex.project), 'an emptied registry ([]) exports no project.tags key');+ // A malformed registry is dropped, never stored, never a 500.+ r = await post(D, '/save', { v: 4, items: [], phases: [], order: [], deleted: [], added: [], tags: 'nope' });+ ok(r.status === 200 && !('tags' in JSON.parse(fs.readFileSync(path.join(D.dir, 'plan-state.json'), 'utf8'))), 'a registry that is not a list is dropped');+ // /compact folds the registry into the data file; then the data file's stands.+ await post(D, '/save', { v: 4, items: [], phases: [], order: [], deleted: [], added: [], tags: [{ name: 'later' }, { name: 'Tech', color: '#00b8b1' }] });+ r = await post(D, '/compact', {});+ const onDiskD = JSON.parse(fs.readFileSync(D.data, 'utf8'));+ ok(r.status === 200 && eq(onDiskD.project.tags, [{ name: 'later' }, { name: 'Tech', color: '#00b8b1' }]), '/compact writes project.tags into the data file', onDiskD.project);+ ex = await getJ(D, '/export/json');+ ok(eq(ex.project.tags, [{ name: 'later' }, { name: 'Tech', color: '#00b8b1' }]), 'with no sidecar the data file\'s registry stands');+ const dataD = await getJ(D, '/data');+ ok(eq(dataD.project.tags, ex.project.tags), '/data serves the registry normalised');+ // Round trip through /import/json, normalised on the way in.+ const jr = basePlan(false);+ jr.project.tags = [{ name: 'Risk', color: '#FF8A7A' }, { name: 'risk' }, { name: 5 }, { name: 'Empty colour', color: '' }];+ r = await post(D, '/import/json', jr);+ ex = await getJ(D, '/export/json');+ ok(r.status === 200 && eq(ex.project.tags, [{ name: 'Risk', color: '#ff8a7a' }, { name: 'Empty colour' }]), '/import/json reads and normalises project.tags', ex.project.tags);+ const back = await getJ(D, '/export/json');+ r = await post(D, '/import/json', back);+ ok(r.status === 200 && eq((await getJ(D, '/export/json')).project.tags, back.project.tags), 'an export imports back with the same registry');+ const jn = basePlan(false); jn.project.tags = [];+ await post(D, '/import/json', jn);+ ok(!('tags' in JSON.parse(fs.readFileSync(D.data, 'utf8')).project), 'an imported empty registry is stored as no key');+ // The changelog.+ const lgD = await post(D, '/log', { actor: 'user', events: [+ { action: 'tag.create', tag: 'someday', to: { color: '' }, via: 'setup' },+ { action: 'tag.create', tag: 'Risk', to: { color: '#ff8a7a' }, via: 'setup' },+ { action: 'tag.color', tag: 'Tech', from: { color: '' }, to: { color: '#8c6bf7' }, via: 'setup' },+ { action: 'tag.color', tag: 'Tech', from: { color: '#8c6bf7' }, to: { color: '#00b8b1' }, via: 'redo' },+ { action: 'tag.color', tag: 'Tech', from: { color: '#00b8b1' }, to: { color: '' }, via: 'setup' },+ { action: 'tag.rename', tag: 'later', from: { name: 'someday' }, to: { name: 'later' }, via: 'setup' },+ { action: 'tag.delete', tag: 'later', from: { color: '' }, via: 'undo' },+ { action: 'tag.color', tag: 'evil\nline', from: 5, to: { color: ['x'] } },+ ] });+ ok(lgD.status === 200 && lgD.j.added === 8, '/log accepts the tag.* actions', lgD);+ const mdD = await getT(D, '/changelog?format=md');+ const linesD = ['user created the tag "someday" (setup)', 'user created the tag "Risk" in #ff8a7a (setup)', 'user coloured the tag "Tech" #8c6bf7 (setup)',+ 'user recoloured the tag "Tech" #8c6bf7 -> #00b8b1 (redo)', 'user cleared the colour on the tag "Tech" (was #00b8b1) (setup)',+ 'user renamed the tag "someday" to "later" (setup)', 'user removed the tag "later" from the plan\'s tag list (undo)', 'user cleared the colour on the tag "evil line"'];+ const missingD = linesD.filter(l => !mdD.includes(l));+ ok(missingD.length === 0, 'the markdown changelog reads each tag.* event as a sentence, junk fields included', missingD);+ stop(D); } try { await main(); }
test/ui_test.mjs+248−63@@ -866,7 +866,7 @@ async function main() { .map(x => (x.e.className || x.e.tagName) + ' @' + Math.round(x.r.right)).slice(0, 4)); check('Setup lists all 20 sections', await page.$$eval('#setupView .sec-row', e => e.length) === 20); for (const [what, open] of [['setup', null], ['setup, rename open', 'edit'], ['setup, remove open', 'del']]) {- if (open) { await page.click(`.sec-row[data-i="1"] .sec-btn[data-a="${open}"]`); await settle(page, 150); }+ if (open) { await page.click(open === 'edit' ? '.sec-row[data-i="1"] .sec-nm-t' : `.sec-row[data-i="1"] .sec-btn[data-a="${open}"]`); await settle(page, 150); } await assertNoSidewaysScroll(page, what); const o = await setupOver(); check(`${what}: nothing on the page runs past the window`, o.length === 0, o.join(' | '));@@ -893,7 +893,11 @@ async function main() { section = 'coverage'; // 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');+ // And a tag registry (0.61.0): two coloured tags and one that nothing carries, so the+ // coloured chips, the Tags card's swatches and an unused tag's row are walked too.+ const covPlan = seedPlan({ tags: true });+ covPlan.project.tags = [{ name: 'Launch', color: '#00b8b1' }, { name: 'long-lead', color: '#e8b64a' }, { name: 'someday' }];+ const ST = await startServer(covPlan, 'plan20-tags'); await coverageGate(browser, ST.url); } @@ -1087,14 +1091,17 @@ const MANIFEST = [ { sel: '.dd-item', within: 'importMenu', label: 'import JSON / CSV', terminal: true, by: 'import menu', key: c => c.ds.fmt, pin: ['json', 'csv'] }, { sel: '.dd-item', within: 'importMenu', label: 'import MS Project XML / Jira CSV', untested: 'the import flow checks the file chooser opens for these with the right accept filter, but no MS Project or Jira file is parsed', key: c => c.ds.fmt, pin: ['msproject-xml', 'jira-csv'] }, { sel: '.sec-sw', label: 'section colour', terminal: true, by: 'sections', key: c => c.title, pin: ['Colour'] },- { sel: '.sec-btn', label: 'section row actions', terminal: true, by: 'sections', key: c => c.ds.a, pin: ['up', 'down', 'edit', 'del'] },+ // 0.61.0: the up/down arrows and Edit are gone (a row is dragged, or moved with Alt+Up/Down,+ // and its name and aside are edited by a click on them). Remove is the one row button left.+ { sel: '.sec-btn', label: 'section row actions', terminal: true, by: 'sections', key: c => c.ds.a, pin: ['del'] },+ { sel: '.sec-nm-t', label: 'section name: click to edit in place', terminal: true, by: 'sections', key: c => c.ds.f, pin: ['name'] },+ { sel: '.sec-ld-t', label: 'section aside: click to edit in place', terminal: true, by: 'sections', key: c => c.ds.f, pin: ['lead'] }, { sel: '#secNew', label: 'new section name', terminal: true, by: 'sections' }, { sel: '#secAdd', label: 'add section', terminal: true, by: 'sections' }, // The Setup page's inline row editors (0.58.0: they were never opened by the gate while // they lived in the modal, so they were not enumerated; on the page they are states).- { sel: '.sec-nm', label: 'section rename: name', terminal: true, by: 'sections' },- { sel: '.sec-ld', label: 'section rename: aside', terminal: true, by: 'sections' },- { sel: '.sec-ok', label: 'section rename: Save', terminal: true, by: 'sections' },+ { sel: '.sec-nm', label: 'section name, in place', terminal: true, by: 'sections' },+ { sel: '.sec-ld', label: 'section aside, in place', terminal: true, by: 'sections' }, { sel: '.sec-chip', label: 'section colour palette', terminal: true, by: 'sections', key: c => c.title, pin: ['Theme', 'Teal', 'Purple', 'Blue', 'Green', 'Orange', 'Red', 'Slate'] }, { sel: '.sec-hex', label: 'section colour: hex', terminal: true, by: 'sections' }, { sel: '.sec-dest', label: 'section remove: destination', terminal: true, by: 'sections' },@@ -1117,10 +1124,15 @@ const MANIFEST = [ { 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 },+ // 0.61.0: Rename is a click on the tag's name; the card also makes tags and colours them.+ { sel: '.tag-btn', label: 'Tags card: remove everywhere / no colour', terminal: true, by: 'tags', key: c => c.ds.a, pin: ['del', 'nocolor'] },+ { sel: '.tag-nm-t', label: 'Tags card: click a name to rename it everywhere', terminal: true, by: 'tags' },+ { sel: '.tag-nm', label: 'Tags card: new name, in place', terminal: true, by: 'tags' },+ { sel: '#tagNew', label: 'Tags card: new tag', terminal: true, by: 'tags' },+ { sel: '#tagAdd', label: 'Tags card: add the new tag', terminal: true, by: 'tags' },+ { sel: '.tag-sw', label: 'Tags card: a tag\'s colour swatch (opens the palette)', terminal: true, by: 'tags' },+ { sel: '.tag-pc', label: 'tag colour palette', terminal: true, by: 'tags', key: c => c.ds.hex, pin: ['#00b8b1', '#8c6bf7', '#64abff', '#ff8a7a', '#e8b64a', '#5fd7a3', '#ec7ab8', '#6f7cff', '#8b9bb4'] },+ { sel: '.tag-hex', label: 'tag colour: hex', terminal: true, by: 'tags' }, { 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' },@@ -1269,6 +1281,12 @@ async function setVal(page, sel, v) { const editorShown = page => page.$eval('#editor', e => e.classList.contains('show') && window.__uiVis(e)); // Escape steps back one rung at a time (field, row, selection); three clear everything. async function clearSel(page) { await page.mouse.click(60, 22); for (let i = 0; i < 3; i++) await page.keyboard.press('Escape'); await settle(page, 100); }+// 0.61.0: a tag is renamed by clicking its name in the Tags card and typing over it.+async function renameTag(page, key, to) {+ await page.click(`#suTags .tag-row[data-tag="${key}"] .tag-nm-t`); await settle(page, 100);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');+ await page.keyboard.type(to); await page.keyboard.press('Enter'); await settle(page, 250);+} async function undo(page) { await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page, 250); } async function coverageGate(browser, url) {@@ -1292,10 +1310,12 @@ async function coverageGate(browser, url) { ['import menu', async () => { await page.click('#btnImport'); }], ['help menu', async () => { await page.click('#btnHelp'); }], ['setup', async () => { await view(page, 'setup'); }],- ['setup: rename', async () => { await view(page, 'setup'); await page.click('.sec-row[data-i="1"] .sec-btn[data-a="edit"]'); }],+ ['setup: rename', async () => { await view(page, 'setup'); await page.click('.sec-row[data-i="1"] .sec-nm-t'); }],+ ['setup: aside', async () => { await view(page, 'setup'); await page.click('.sec-row[data-i="1"] .sec-ld-t'); }], ['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"]'); }],+ ['setup: tag rename', async () => { await view(page, 'setup'); await page.click('#suTags .tag-row .tag-nm-t'); }],+ ['setup: tag colour', async () => { await view(page, 'setup'); await page.click('#suTags .tag-row[data-tag="launch"] .tag-sw'); }], ['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'); }],@@ -1331,7 +1351,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 [['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']])+ for (const [st, sel] of [['setup: rename', '.sec-nm'], ['setup: aside', '.sec-ld'], ['setup: tag rename', '.tag-nm'], ['setup: tag colour', '.tag-hex'], ['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(' | '));@@ -1694,6 +1714,22 @@ async function sectionsFlow(browser) { }; const idx = async name => (await listNames()).indexOf(name); const rowBtn = async (name, a) => page.click(`.sec-row[data-i="${await idx(name)}"] .sec-btn[data-a="${a}"]`);+ // 0.61.0: the arrows and Edit are gone. A row moves by a drag (real mouse events, from its+ // grip) or by Alt+Up/Down with the row focused; a name or an aside is edited by a click.+ const rowGrip = async (name, pg = page) => pg.$eval(`.sec-row[data-i="${(await listNames(pg)).indexOf(name)}"] .sec-grip`, e => { e.scrollIntoView({ block: 'center' }); const b = e.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; });+ const dragTo = async (name, to, { pg = page, probe = false } = {}) => {+ const from = (await listNames(pg)).indexOf(name);+ const gp = await rowGrip(name, pg);+ const tb = await pg.$eval(`.sec-row[data-i="${to}"]`, r => { const b = r.getBoundingClientRect(); return { top: b.top, bottom: b.bottom }; });+ const y = to < from ? tb.top + 4 : to > from ? tb.bottom - 4 : (tb.top + tb.bottom) / 2;+ await pg.mouse.move(gp.x, gp.y); await pg.mouse.down();+ for (let k = 1; k <= 10; k++) { await pg.mouse.move(gp.x + k, gp.y + (y - gp.y) * k / 10); await sleep(15); }+ const seen = probe ? await pg.evaluate(() => { const d = document.querySelector('.sec-list .sec-drop'); return !!d && getComputedStyle(d).display !== 'none' && !!document.querySelector('.sec-row.sec-dragging'); }) : null;+ await pg.mouse.up(); await settle(pg, 250);+ return seen;+ };+ const altMove = async (name, key, pg = page) => { await pg.focus(`.sec-row[data-i="${(await listNames(pg)).indexOf(name)}"]`); await pg.keyboard.down('Alt'); await pg.keyboard.press(key); await pg.keyboard.up('Alt'); await settle(pg, 200); };+ const fieldClick = async (name, f, pg = page) => { await pg.click(`.sec-row[data-i="${(await listNames(pg)).indexOf(name)}"] ${f === 'name' ? '.sec-nm-t' : '.sec-ld-t'}`); await settle(pg, 120); }; const redo = async (pg = page) => { await pg.keyboard.down('Control'); await pg.keyboard.down('Shift'); await pg.keyboard.press('KeyZ'); await pg.keyboard.up('Shift'); await pg.keyboard.up('Control'); await settle(pg, 250); }; const axisText = pg => pg.evaluate(() => [...document.querySelectorAll('.q-row > *, .m-row > *')].map(c => c.textContent).join(' ')); @@ -1736,53 +1772,107 @@ async function sectionsFlow(browser) { check('after adding sections and a reload the chart axis is the plan\'s own years, not 1970', ax.includes('2026') && !/19(69|70)/.test(ax), ax.slice(0, 120)); await view(page, 'setup'); - // ── Reorder ──- await rowBtn('Alpha', 'down'); await settle(page, 200);- await expectState('Move down swaps a section with the next and its rows go with it', N = ['Beta', 'Alpha', 'Gamma', 'Delta', 'Echo', 'Foxtrot']);- await rowBtn('Alpha', 'up'); await settle(page, 200);- await expectState('Move up swaps it back', N = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Echo', 'Foxtrot']);+ // ── Reorder: drag a row (0.61.0), with real mouse events ──+ check('the up and down arrows and the Edit button are gone', !(await page.$('.sec-btn[data-a="up"], .sec-btn[data-a="down"], .sec-btn[data-a="edit"]')));+ check('every row has a grip and says how to move it, keys included', await page.$$eval('#setupView .sec-row', rs => rs.every(r => r.querySelector('.sec-grip') && /drag/i.test(r.title) && /Alt\+Up/.test(r.title))));+ const phaseReorders = async () => (await (await fetch(X.url + 'changelog')).json()).events.filter(e => e.action === 'phase.reorder');+ let nr0 = (await phaseReorders()).length;+ const shown = await dragTo('Gamma', 0, { probe: true });+ check('mid-drag the row is lifted and a drop line shows where it will land', shown === true);+ await expectState('dragging row 3 to the top moves it there and its rows go with it', N = ['Gamma', 'Alpha', 'Beta', 'Delta', 'Echo', 'Foxtrot']);+ let RO = (await phaseReorders()).slice(nr0);+ check('the drop is ONE phase.reorder event, as the arrows logged', RO.length === 1 && RO[0].phase === 'Gamma' && RO[0].from.i === 2 && RO[0].to.i === 0 && RO[0].via === 'sections', JSON.stringify(RO));+ check('the dropped row is focused, so Alt+Up/Down can carry on', await page.evaluate(() => document.activeElement && document.activeElement.classList.contains('sec-row') && document.activeElement.dataset.i === '0'));+ await page.evaluate(() => document.activeElement.blur()); await undo(page);- await expectState('Ctrl+Z undoes the move up', ['Beta', 'Alpha', 'Gamma', 'Delta', 'Echo', 'Foxtrot']);+ await expectState('ONE Ctrl+Z puts the dragged section back', N = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Echo', 'Foxtrot']); await redo();- await expectState('redo moves it up again', N);+ await expectState('redo drags it to the top again', ['Gamma', 'Alpha', 'Beta', 'Delta', 'Echo', 'Foxtrot']);+ await undo(page);+ await expectState('(undone, for what follows)', N);+ // A press that moves a few pixels is a click, not a drag.+ nr0 = (await phaseReorders()).length;+ const g = await rowGrip('Beta');+ await page.mouse.move(g.x, g.y); await page.mouse.down(); await page.mouse.move(g.x + 2, g.y + 2); await page.mouse.up(); await settle(page, 250);+ check('a press that moves 2px is a click: nothing moves and nothing is logged', JSON.stringify(await listNames()) === JSON.stringify(N) && (await phaseReorders()).length === nr0 && !(await page.$('.sec-drop')));+ // Dropping a row where it already is changes nothing.+ await dragTo('Beta', 1);+ check('a drag dropped back where it started changes nothing', JSON.stringify(await listNames()) === JSON.stringify(N) && (await phaseReorders()).length === nr0);+ // The keyboard path.+ await altMove('Alpha', 'ArrowDown');+ await expectState('Alt+Down on a focused row moves it down one', N = ['Beta', 'Alpha', 'Gamma', 'Delta', 'Echo', 'Foxtrot']);+ check('and the focus stays on the moved row', await page.evaluate(() => document.activeElement?.querySelector('.sec-nm-t')?.textContent) === 'Alpha');+ await page.keyboard.down('Alt'); await page.keyboard.press('ArrowUp'); await page.keyboard.up('Alt'); await settle(page, 200);+ await expectState('Alt+Up moves it back', N = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Echo', 'Foxtrot']);+ await page.evaluate(() => document.activeElement.blur()); // V-A: a populated section moved PAST sections added since the data file was written. // Its rows' saved indices are beyond the data file's list, and until the review a reload // checked them against that list, kept the old index and put them in another section.- await rowBtn('Delta', 'down'); await rowBtn('Delta', 'down'); await settle(page, 200);- await expectState('Delta moved down past the two new sections', N = ['Alpha', 'Beta', 'Gamma', 'Echo', 'Foxtrot', 'Delta']);+ await dragTo('Delta', 5);+ await expectState('Delta dragged down past the two new sections', N = ['Alpha', 'Beta', 'Gamma', 'Echo', 'Foxtrot', 'Delta']); await page.reload({ waitUntil: 'networkidle0' }); await settle(page); await expectState('after a reload every row is still in its own section', N); const snapHtml0 = await (await fetch(X.url + 'export/html')).text(); - // ── Rename and aside: one Save, one undo ──- await rowBtn('Gamma', 'edit'); await settle(page, 100);+ // ── Click to edit: the name and the aside, in place (0.61.0) ── let gi = await idx('Gamma');- await page.$eval(`.sec-row[data-i="${gi}"] .sec-nm`, e => { e.value = 'Gamma renamed'; });- await page.$eval(`.sec-row[data-i="${gi}"] .sec-ld`, e => { e.value = 'the long pole'; });- await page.click(`.sec-row[data-i="${gi}"] .sec-ok`); await settle(page, 200);+ const G = `.sec-row[data-i="${gi}"]`;+ await fieldClick('Gamma', 'name');+ check('clicking the name opens it in place, focused, with the text selected', await page.evaluate(s => { const i = document.querySelector(s + ' .sec-name input.sec-nm'); return !!i && document.activeElement === i && i.selectionStart === 0 && i.selectionEnd === i.value.length && i.value === 'Gamma'; }, G));+ await page.keyboard.type('Gamma renamed'); await page.keyboard.press('Enter'); await settle(page, 200); let pl = await savedPlan(X.url);- check('Edit renames a section and sets its aside', pl.phases[gi].name === 'Gamma renamed' && pl.phases[gi].lead === 'the long pole', JSON.stringify(pl.phases[gi]));+ check('Enter renames the section', pl.phases[gi].name === 'Gamma renamed' && !(await page.$('.sec-row.sec-editing')), JSON.stringify(pl.phases[gi])); HOME = Object.fromEntries(Object.entries(HOME).map(([k, v]) => [k, v === 'Gamma' ? 'Gamma renamed' : v])); N = N.map(n => n === 'Gamma' ? 'Gamma renamed' : n);+ await page.hover('#suSections h3'); await page.evaluate(() => document.activeElement && document.activeElement.blur()); await settle(page, 80);+ const aside = await page.$eval(`${G} .sec-ld-t`, e => ({ empty: e.classList.contains('empty'), t: e.textContent, o: getComputedStyle(e).opacity }));+ // Hidden (opacity 0) where the pointer can hover; faint (0.55) where it cannot, so a finger can find it.+ check('an empty aside is a quiet "add an aside" placeholder, hidden or faint until the row is hovered', aside.empty && aside.t === 'add an aside' && +aside.o < 0.6, JSON.stringify(aside));+ await page.hover(`${G} .sec-count`); await settle(page, 80);+ check('and shown on hover', await page.$eval(`${G} .sec-ld-t`, e => getComputedStyle(e).opacity) === '1');+ await fieldClick('Gamma renamed', 'lead');+ await page.keyboard.type('the long pole');+ await page.click('#suSections h3'); await settle(page, 250);+ pl = await savedPlan(X.url);+ check('clicking the aside edits it in place, and clicking away commits it', pl.phases[gi].lead === 'the long pole' && pl.phases[gi].name === 'Gamma renamed' && !(await page.$('.sec-row.sec-editing')), JSON.stringify(pl.phases[gi]));+ check('the page shows the new aside', await page.$eval(`${G} .sec-ld-t`, e => e.textContent) === 'the long pole'); await undo(page); pl = await savedPlan(X.url);- check('ONE Ctrl+Z takes back both the name and the aside', pl.phases[gi].name === 'Gamma' && !pl.phases[gi].lead, JSON.stringify(pl.phases[gi]));- await redo();+ check('Ctrl+Z takes back the aside alone', pl.phases[gi].name === 'Gamma renamed' && !pl.phases[gi].lead, JSON.stringify(pl.phases[gi]));+ await undo(page); pl = await savedPlan(X.url);- check('one redo puts both back', pl.phases[gi].name === 'Gamma renamed' && pl.phases[gi].lead === 'the long pole', JSON.stringify(pl.phases[gi]));- await rowBtn('Gamma renamed', 'edit'); await settle(page, 100);- await page.$eval(`.sec-row[data-i="${gi}"] .sec-nm`, e => { e.value = 'ALPHA'; });- await page.click(`.sec-row[data-i="${gi}"] .sec-ok`); await settle(page, 200);- check('rename refuses a name another section has, and says why', await page.$eval(`.sec-row[data-i="${gi}"] .sec-msg`, e => /already a section called/.test(e.textContent)).catch(() => false)- && (await savedPlan(X.url)).phases[gi].name === 'Gamma renamed');+ check('a second Ctrl+Z takes back the rename', pl.phases[gi].name === 'Gamma' && (await listNames())[gi] === 'Gamma', JSON.stringify(pl.phases[gi]));+ await redo(); await redo();+ pl = await savedPlan(X.url);+ check('two redos put both back', pl.phases[gi].name === 'Gamma renamed' && pl.phases[gi].lead === 'the long pole', JSON.stringify(pl.phases[gi]));+ // Escape cancels; a click away commits.+ await fieldClick('Gamma renamed', 'name'); await page.keyboard.type('Nope'); await page.keyboard.press('Escape'); await settle(page, 200);+ check('Escape cancels an edit in place', !(await page.$('.sec-row.sec-editing')) && (await listNames())[gi] === 'Gamma renamed' && (await savedPlan(X.url)).phases[gi].name === 'Gamma renamed');+ await fieldClick('Echo', 'name'); await page.keyboard.type('Echo blurred'); await page.click('#suSections h3'); await settle(page, 250);+ check('a click away commits a name', (await savedPlan(X.url)).phases[await idx('Echo blurred')]?.name === 'Echo blurred');+ N = N.map(n => n === 'Echo' ? 'Echo blurred' : n);+ // Refusals, with the existing message.+ await fieldClick('Gamma renamed', 'name'); await page.keyboard.type('ALPHA'); await page.keyboard.press('Enter'); await settle(page, 200);+ check('rename refuses a name another section has, says why, and stays open', await page.$eval(`${G} .sec-msg`, e => /already a section called/.test(e.textContent)).catch(() => false)+ && !!(await page.$(`${G} input.sec-nm`)) && (await savedPlan(X.url)).phases[gi].name === 'Gamma renamed');+ await page.click('#suSections h3'); await settle(page, 250);+ check('a click away does not commit a refused name either', (await savedPlan(X.url)).phases[gi].name === 'Gamma renamed' && await page.$eval(`${G} .sec-msg`, e => /already a section called/.test(e.textContent)).catch(() => false)); await page.keyboard.press('Escape'); await settle(page, 150);- check('Escape in the rename field cancels it', !(await page.$('.sec-row.sec-editing')) && (await listNames())[gi] === 'Gamma renamed');+ check('Escape with nothing focused cancels the refused edit', !(await page.$('.sec-row.sec-editing')) && (await listNames())[gi] === 'Gamma renamed');+ await fieldClick('Gamma renamed', 'name'); await page.keyboard.press('Backspace'); await page.keyboard.press('Enter'); await settle(page, 200);+ check('an empty name is refused and says why', await page.$eval(`${G} .sec-msg`, e => /needs a name/.test(e.textContent)).catch(() => false) && (await savedPlan(X.url)).phases[gi].name === 'Gamma renamed');+ await page.keyboard.press('Escape'); await settle(page, 150);+ check('Escape in the name field cancels it', !(await page.$('.sec-row.sec-editing')) && (await listNames())[gi] === 'Gamma renamed'); await rowBtn('Alpha', 'del'); await settle(page, 100); await page.focus('.sec-row.sec-editing .sec-go'); await page.keyboard.press('Escape'); await settle(page, 150); check('Escape with the focus on a row button cancels that row too', !(await page.$('.sec-row.sec-editing')));+ // Keyboard: Enter on a focused name opens it.+ await page.focus(`${G} .sec-nm-t`); await page.keyboard.press('Enter'); await settle(page, 100);+ check('Enter on a focused name opens it for editing', await page.evaluate(s => document.activeElement === document.querySelector(s + ' input.sec-nm'), G));+ await page.keyboard.press('Escape'); await settle(page, 100); // ── Colour ──- await rowBtn('Gamma renamed', 'edit'); await page.click(`.sec-row[data-i="${gi}"] .sec-sw`); await settle(page, 100);+ await rowBtn('Alpha', 'del'); await page.click(`.sec-row[data-i="${gi}"] .sec-sw`); await settle(page, 100); check('opening a second row editor closes the first', await page.$$eval('.sec-row.sec-editing', e => e.length) === 1 && !!(await page.$('.sec-row.sec-editing .sec-pal'))); const hex = await page.$eval(`.sec-row[data-i="${gi}"] .sec-chip[data-hex^="#"]`, b => { b.click(); return b.dataset.hex; }); await settle(page, 200); check('the colour swatch recolours the section', (await savedPlan(X.url)).phases[gi].color === hex);@@ -1803,16 +1893,16 @@ async function sectionsFlow(browser) { check('the name and aside are untouched by the colour undo and redo', pl.phases[gi].name === 'Gamma renamed' && pl.phases[gi].lead === 'the long pole'); // ── D-1: an open Remove form survives an undo and still means the section it was opened on ──- await rowBtn('Beta', 'down'); await settle(page, 200); // Alpha, Gamma renamed, Beta, ...+ await altMove('Beta', 'ArrowDown'); await page.evaluate(() => document.activeElement.blur()); // Alpha, Gamma renamed, Beta, ... await rowBtn('Beta', 'del'); await settle(page, 100); // the form, focus on its select await undo(page); // Beta goes back ABOVE Gamma renamed check('after Ctrl+Z the open Remove form is on Beta\'s new row', await page.$eval('.sec-row.sec-editing .sec-name', e => e.firstChild.textContent) === 'Beta'); await page.click('.sec-row.sec-editing .sec-go'); await settle(page, 200); HOME = Object.fromEntries(Object.entries(HOME).map(([k, v]) => [k, v === 'Beta' ? 'Alpha' : v]));- await expectState('and Move and remove removes Beta (into Alpha), not the section now in its old slot', N = ['Alpha', 'Gamma renamed', 'Echo', 'Foxtrot', 'Delta']);+ await expectState('and Move and remove removes Beta (into Alpha), not the section now in its old slot', N = ['Alpha', 'Gamma renamed', 'Echo blurred', 'Foxtrot', 'Delta']); await undo(page); const HOME1 = Object.fromEntries(Object.entries(HOME).map(([k, v]) => [k, k === 'Beta work' ? 'Beta' : v]));- await expectState('Ctrl+Z of a remove puts the section and its rows back', ['Alpha', 'Beta', 'Gamma renamed', 'Echo', 'Foxtrot', 'Delta'], HOME1);+ await expectState('Ctrl+Z of a remove puts the section and its rows back', ['Alpha', 'Beta', 'Gamma renamed', 'Echo blurred', 'Foxtrot', 'Delta'], HOME1); await redo(); await expectState('redo removes it again', N); @@ -1821,11 +1911,11 @@ async function sectionsFlow(browser) { await page.$eval('.sec-row.sec-editing .sec-dest', s => { s.value = [...s.options].find(o => o.textContent === 'Delta').value; }); await page.click('.sec-row.sec-editing .sec-go'); await settle(page, 200); HOME = Object.fromEntries(Object.entries(HOME).map(([k, v]) => [k, v === 'Gamma renamed' ? 'Delta' : v]));- await expectState('Remove a middle section into a later one', N = ['Alpha', 'Echo', 'Foxtrot', 'Delta']);- await rowBtn('Echo', 'del'); await settle(page, 100);+ await expectState('Remove a middle section into a later one', N = ['Alpha', 'Echo blurred', 'Foxtrot', 'Delta']);+ await rowBtn('Echo blurred', 'del'); await settle(page, 100); await page.click('.sec-row.sec-editing .sec-no'); await settle(page, 100); check('Cancel on a remove leaves the section alone', JSON.stringify(await listNames()) === JSON.stringify(N));- await rowBtn('Echo', 'del'); await settle(page, 100);+ await rowBtn('Echo blurred', 'del'); await settle(page, 100); await page.click('.sec-row.sec-editing .sec-go'); await settle(page, 200); await expectState('Remove deletes an empty section', N = ['Alpha', 'Foxtrot', 'Delta']); @@ -1860,10 +1950,8 @@ async function sectionsFlow(browser) { check('the snapshot opens with the sections and their rows as saved', JSON.stringify(await listNames(sp)) === JSON.stringify(SN) && JSON.stringify(await counts(sp)) === '[2,1,2,0,0,1]', `${await listNames(sp)} ${await counts(sp)}`); await sp.type('#secNew', 'Offline'); await sp.click('#secAdd'); await settle(sp, 300); check('the snapshot adds a section with no server', JSON.stringify(await listNames(sp)) === JSON.stringify([...SN, 'Offline']));- await sp.click('.sec-row[data-i="1"] .sec-btn[data-a="edit"]'); await settle(sp, 100);- await sp.$eval('.sec-row[data-i="1"] .sec-nm', e => { e.value = 'Beta offline'; });- await sp.click('.sec-row[data-i="1"] .sec-ok'); await settle(sp, 200);- await sp.click('.sec-row[data-i="0"] .sec-btn[data-a="down"]'); await settle(sp, 200);+ await fieldClick('Beta', 'name', sp); await sp.keyboard.type('Beta offline'); await sp.keyboard.press('Enter'); await settle(sp, 200);+ await dragTo('Beta offline', 0, { pg: sp }); SN = ['Beta offline', 'Alpha', 'Gamma', 'Echo', 'Foxtrot', 'Delta', 'Offline']; check('the snapshot renames and reorders', JSON.stringify(await listNames(sp)) === JSON.stringify(SN) && JSON.stringify(await counts(sp)) === '[1,2,2,0,0,1,0]', `${await listNames(sp)} ${await counts(sp)}`); await sp.click('.sec-row[data-i="2"] .sec-btn[data-a="del"]'); await settle(sp, 100);@@ -1979,9 +2067,7 @@ async function tagsFlow(browser) { // ── 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);+ await renameTag(page, 'tech', 'Engineering'); 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);@@ -1992,21 +2078,26 @@ async function tagsFlow(browser) { 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);+ await renameTag(page, 'long-lead', 'engineering'); 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')));+ await page.click('#suTags .tag-row[data-tag="ops"] .tag-nm-t'); await settle(page, 100);+ check('clicking a tag\'s name opens it for renaming in place, text selected', await page.evaluate(() => { const i = document.querySelector('#suTags .tag-row[data-tag="ops"] .tag-nm'); return !!i && document.activeElement === i && i.selectionStart === 0 && i.selectionEnd === 3; }));+ await page.keyboard.type('Nope'); await page.keyboard.press('Escape'); await settle(page, 100);+ check('Escape in a tag\'s rename box cancels it', !(await page.$('#suTags .tag-nm')) && JSON.stringify(tagsOf(await savedPlan(X.url))) === JSON.stringify(W));+ await page.click('#suTags .tag-row[data-tag="ops"] .tag-nm-t'); await settle(page, 100);+ await page.keyboard.type('Operations'); await page.click('#suTags h3'); await settle(page, 300);+ await expectTags('a click away commits a tag rename', { ...W, 'Ops · Supply': ['Operations'] });+ await undo(page);+ await expectTags('(and Ctrl+Z takes it back)', W); // ── 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`)]]) {+ for (const [what, a, focus] of [['in another section\'s tag box', 'del', async () => page.focus(`${await rowSel('Misc')} .sec-tag-in`)],+ ['in the new-section box', 'del', 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);@@ -2089,9 +2180,7 @@ async function tagsFlow(browser) { // 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 renameTag(page, 'launch', 'Go'); 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 }));@@ -2125,6 +2214,102 @@ async function tagsFlow(browser) { W['Launch · Gates'] = ['Launch']; W['Launch · Party'] = ['Launch']; await view(page, 'setup'); await expectTags('(that undo put Launch back)', W); await view(page, 'gantt'); + // ── The tag registry (0.61.0): New tag, colours, persistence ──+ await view(page, 'setup');+ const reg = async () => (await savedPlan(X.url)).project.tags || [];+ const regOf = async name => (await reg()).find(t => t.name === name);+ const regLog = async () => (await (await fetch(X.url + 'changelog')).json()).events.filter(e => /^tag\./.test(e.action));+ check('a plan that never used the registry exports no project.tags key', !('tags' in (await exportPlan(X.url)).project));+ const rl0 = (await regLog()).length;+ await page.click('#tagNew'); await page.keyboard.type('someday'); await page.keyboard.press('Enter'); await settle(page, 300);+ check('New tag (Enter) adds a tag nothing carries yet, listed as "0 sections"', await page.$eval('#suTags .tag-row[data-tag="someday"] .tag-n', e => e.textContent).catch(() => '') === '0 sections');+ check('it is saved in project.tags', JSON.stringify(await reg()) === '[{"name":"someday"}]', JSON.stringify(await reg()));+ check('it is offered as a suggestion wherever tags are typed', (await page.$$eval('#tagList option', o => o.map(x => x.value))).includes('someday'));+ check('the box is empty and keeps the focus for the next one', await page.evaluate(() => document.activeElement.id === 'tagNew' && document.activeElement.value === ''));+ await page.keyboard.type('ENGINEERING'); await page.keyboard.press('Enter'); await settle(page, 200);+ check('New tag refuses a tag the plan already has (any case), and says why', /already a tag called/.test(await page.$eval('#setupView .tag-err', e => window.__uiVis(e) ? e.textContent : ''))+ && JSON.stringify(await reg()) === '[{"name":"someday"}]');+ await page.$eval('#tagNew', e => { e.value = ''; e.dispatchEvent(new Event('input')); e.blur(); });+ await view(page, 'gantt');+ check('an unused tag gets no legend chip (it would filter to nothing)', !(await page.$('.tag-chip[data-tag="someday"]')) && !!(await page.$('.tag-chip[data-tag="engineering"]')));+ await view(page, 'setup');+ await undo(page);+ check('Ctrl+Z takes the new tag back', !(await page.$('#suTags .tag-row[data-tag="someday"]')) && (await reg()).length === 0);+ check('and an emptied registry exports no project.tags key', !('tags' in (await exportPlan(X.url)).project));+ await redo();+ check('redo makes it again', !!(await page.$('#suTags .tag-row[data-tag="someday"]')) && JSON.stringify(await reg()) === '[{"name":"someday"}]');+ // Colour from the palette.+ await page.click('#suTags .tag-row[data-tag="engineering"] .tag-sw'); await settle(page, 150);+ const PAL = ['#00b8b1', '#8c6bf7', '#64abff', '#ff8a7a', '#e8b64a', '#5fd7a3', '#ec7ab8', '#6f7cff', '#8b9bb4'];+ check('the swatch opens a palette: the brand colours first, six more, a hex box and No colour', JSON.stringify(await page.$$eval('#suTags .tag-pal .tag-pc', b => b.map(x => x.dataset.hex))) === JSON.stringify(PAL)+ && !!(await page.$('#suTags .tag-pal .tag-hex')) && !!(await page.$('#suTags .tag-pal .tag-btn[data-a="nocolor"]')));+ await page.click('#suTags .tag-pal .tag-pc[data-hex="#8c6bf7"]'); await settle(page, 300);+ check('a palette colour is saved on the tag', (await regOf('Engineering'))?.color === '#8c6bf7', JSON.stringify(await reg()));+ check('the palette closes and the swatch shows the colour', !(await page.$('#suTags .tag-pal')) && await page.$eval('#suTags .tag-row[data-tag="engineering"] .tag-sw', e => getComputedStyle(e).backgroundColor) === 'rgb(140, 107, 247)');+ // Colour from the hex box, and its refusal.+ await page.click('#suTags .tag-row[data-tag="ops"] .tag-sw'); await settle(page, 150);+ await page.keyboard.type('zzz'); await page.keyboard.press('Enter'); await settle(page, 150);+ check('a hex that is not a colour is refused, and says why', /not a colour/.test(await page.$eval('#suTags .tag-pal .sec-msg', e => e.textContent).catch(() => '')) && !(await regOf('Ops')));+ await page.$eval('#suTags .tag-hex', e => { e.value = ''; }); await page.type('#suTags .tag-hex', 'E8B'); await page.keyboard.press('Enter'); await settle(page, 300);+ check('a hex colour is saved, as lowercase #rrggbb', (await regOf('Ops'))?.color === '#ee88bb', JSON.stringify(await reg()));+ await page.click('#suTags .tag-row[data-tag="ops"] .tag-sw'); await settle(page, 150);+ await page.keyboard.press('Escape'); await settle(page, 100);+ check('Escape closes a tag\'s palette', !(await page.$('#suTags .tag-pal')));+ await page.click('#suTags .tag-row[data-tag="ops"] .tag-sw'); await settle(page, 150);+ await page.click('#suTags .tag-pal .tag-btn[data-a="nocolor"]'); await settle(page, 300);+ check('No colour takes it off', (await regOf('Ops')) && !(await regOf('Ops')).color);+ await undo(page);+ check('Ctrl+Z puts the colour back', (await regOf('Ops'))?.color === '#ee88bb');+ // Drawn wherever the tag is a chip or a pill: computed styles, both themes.+ await page.click('.vt-btn[data-view="gantt"]'); await settle(page, 250);+ const paint = sel => page.$eval(sel, e => { const cs = getComputedStyle(e); const rgb = s => s.match(/[\d.]+/g).slice(0, 3).map(Number);+ const lum = t => { const f = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; return 0.2126 * f(t[0]) + 0.7152 * f(t[1]) + 0.0722 * f(t[2]); };+ const bg = rgb(getComputedStyle(document.body).backgroundColor), ink = rgb(cs.color);+ const a = lum(ink), b = lum(bg); return { tc: e.dataset.tc, color: cs.color, fill: cs.backgroundColor, border: cs.borderTopColor, ratio: (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) }; });+ await page.evaluate(() => { const c = document.getElementById('tagClear'); if (c && c.isConnected) c.click(); }); await settle(page, 150);+ let lc = await paint('.tag-chip[data-tag="engineering"]');+ check('the legend chip takes the colour: a low-alpha fill of it and an ink that reads (3:1) on the dark page', lc.tc === '#8c6bf7' && lc.fill === 'rgba(140, 107, 247, 0.14)' && lc.ratio >= 3, JSON.stringify(lc));+ const plain = await paint('.tag-chip[data-tag="misc"]');+ check('a tag with no colour looks as it always did', !plain.tc && plain.fill === 'rgba(0, 0, 0, 0)', JSON.stringify(plain));+ check('the section header chips take it too', await page.$$eval('.lane-tag', e => e.filter(x => x.textContent === 'Engineering').every(x => x.dataset.tc === '#8c6bf7' && getComputedStyle(x).backgroundColor === 'rgba(140, 107, 247, 0.14)')));+ await view(page, 'table');+ const tp = await paint('#tableView tbody .row-tag[data-tc="#8c6bf7"]').catch(() => null);+ check('and the Table\'s Tags pills (a section\'s, dashed, in the ink)', !!tp && tp.color === lc.color && tp.ratio >= 3, JSON.stringify(tp));+ await page.click('#btnTheme'); await settle(page, 250);+ const tpl = await paint('#tableView tbody .row-tag[data-tc="#8c6bf7"]');+ await view(page, 'gantt');+ lc = await paint('.tag-chip[data-tag="engineering"]');+ check('on the light theme the ink is walked again against the paper, and still reads', lc.ratio >= 3 && tpl.ratio >= 3 && lc.fill === 'rgba(140, 107, 247, 0.14)', JSON.stringify({ lc, tpl }));+ await shot(page, 'tags-1440-coloured-light');+ await page.click('#btnTheme'); await settle(page, 250);+ // Rename and remove everywhere take the registry entry with them.+ await view(page, 'setup');+ await renameTag(page, 'engineering', 'Eng');+ check('a rename everywhere keeps the colour on the new name', (await regOf('Eng'))?.color === '#8c6bf7' && !(await regOf('Engineering')));+ await undo(page);+ check('and its Ctrl+Z takes the colour back to the old name', (await regOf('Engineering'))?.color === '#8c6bf7' && !(await regOf('Eng')));+ await page.click('#suTags .tag-row[data-tag="ops"] .tag-btn[data-a="del"]'); await settle(page, 300);+ check('Remove everywhere takes the tag out of the registry too', !(await regOf('Ops')) && !tagsOf(await savedPlan(X.url))['Ops · Supply'].length);+ await undo(page);+ check('and Ctrl+Z puts it back with its colour', (await regOf('Ops'))?.color === '#ee88bb' && JSON.stringify(tagsOf(await savedPlan(X.url))) === JSON.stringify(W));+ await renameTag(page, 'someday', 'later');+ check('an unused tag renames in the registry', JSON.stringify((await reg()).map(t => t.name)) === JSON.stringify(['later', 'Engineering', 'Ops']), JSON.stringify(await reg()));+ // The changelog.+ const RL = (await regLog()).slice(rl0).map(e => `${e.action}:${e.tag}:${e.via}`);+ const want = ['tag.create:someday:setup', 'tag.delete:someday:undo', 'tag.create:someday:redo', 'tag.color:Engineering:setup', 'tag.color:Ops:setup', 'tag.color:Ops:setup', 'tag.color:Ops:undo', 'tag.rename:Eng:setup', 'tag.rename:Engineering:undo', 'tag.delete:Ops:setup', 'tag.color:Ops:undo', 'tag.rename:later:setup'];+ check('the changelog has tag.create, tag.color, tag.rename and tag.delete, undo and redo included', JSON.stringify(RL) === JSON.stringify(want), JSON.stringify(RL));+ const mdt = await md();+ check('and reads them as sentences', ['created the tag "someday" (setup)', 'coloured the tag "Engineering" #8c6bf7 (setup)', 'cleared the colour on the tag "Ops" (was #ee88bb) (setup)', 'renamed the tag "someday" to "later" (setup)', 'removed the tag "Ops" from the plan\'s tag list (setup)'].every(t => mdt.includes(t)),+ mdt.split('\n').filter(l => /the tag/.test(l)).slice(-6).join(' | '));+ // Persistence.+ await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await view(page, 'setup');+ check('after a reload the registry and its colours are as saved', JSON.stringify(await page.$$eval('#suTags .tag-row', r => r.map(x => x.dataset.tag + (x.querySelector('.tag-nm-t').dataset.tc || ''))).then(a => a.filter(x => /^(engineering|ops|later)/.test(x))))+ === JSON.stringify(['engineering#8c6bf7', 'ops#ee88bb', 'later']));+ check('and the unused tag is still listed, as 0 sections', await page.$eval('#suTags .tag-row[data-tag="later"] .tag-n', e => e.textContent).catch(() => '') === '0 sections');+ await view(page, 'gantt');+ check('and the legend chip is still coloured', await page.$eval('.tag-chip[data-tag="engineering"]', e => e.dataset.tc) === '#8c6bf7');+ // ── 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');@@ -2151,6 +2336,7 @@ async function tagsFlow(browser) { 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'));+ check('the snapshot carries the tag colours', await sp.$$eval('.lane-tag', e => e.filter(x => x.textContent === 'Engineering').every(x => x.dataset.tc === '#8c6bf7'))); 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');@@ -2431,8 +2617,7 @@ async function rowTagsFlow(browser) { const card = () => page.$$eval('#suTags .tag-row', rows => rows.map(r => r.querySelector('.tag-name').textContent + ':' + r.querySelector('.tag-n').textContent)); check('the Tags card counts sections and rows', JSON.stringify(await card()) === JSON.stringify(['Tech:1 section', 'hot:1 row', 'batchy:2 rows', 'long-lead:3 rows', 'urgent:1 row', 'late:1 row', 'fresh:1 row']), JSON.stringify(await card())); n0 = (await tagLog()).length;- 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 = 'LL'; }); await page.click('#suTags .tag-ok'); await settle(page, 250);+ await renameTag(page, 'long-lead', 'LL'); W['Ops one'] = ['LL']; W['Ops dropped'] = ['LL']; W['Ops two'] = ['urgent', 'LL', 'late']; await expectOwn('Rename everywhere renames the tag on every row, in place', W); L = (await tagLog()).slice(n0);