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.78.2
10 files changed
+540−60
README.md+9−5@@ -56,6 +56,8 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc **The task editor remembers where you put it.** The panel that opens on a bar is a compact 256px square, draggable by the strip at its top. It is meant to be glanced at rather than filled in: the row's name across the top, the two dates, the note, the dependency list (the one part that flexes, and the one part that scrolls) and the buttons, at desktop density, so it sits over as little of the chart it is editing as it can. The full **Edit item** panel, with the name, owner, status and provenance fields, is the one you fill in and it is unchanged. Drag it once and every panel after it opens in that same spot on screen, whatever row it belongs to and however far the chart has been scrolled, until the page is reloaded. Nothing is saved to the plan; it is a property of the sitting, and a refresh restores the automatic placement beside the bar. +**Select a row without opening anything.** Every task and milestone row has a handle on its left edge, six dots that show when you point at the row: in the chart at the start of the name, in the Table at the start of the ID. A click on the handle selects that row and nothing else happens: no rename, no picker, no editor. Ctrl+click on a handle adds the row or takes it out, Shift+click selects the rows in between (only the ones on screen), and Escape clears. Then press **Delete** twice to delete the selection, or use Ctrl+C, Alt+Up and Alt+Down, or Copy IDs. Drag the handle up or down to move the row. A selected row keeps its handle showing, in the accent colour. Your selection is this tab's own and is never saved.+ **Multi-select.** Ctrl+click to toggle individual rows, Shift+click for range select (Windows Explorer style), in either lens. Tasks and milestones both, since both have a permanent id to be named by; lane headers do not join. The batch editor applies relative date shifts across the selected tasks without flattening dates, and says how many milestones it is leaving alone: a milestone has one date and no duration, so shifting its end has no meaning. Its **Delete** removes the whole selection, milestones included, in one press and one Ctrl+Z: the same path the chart's armed Delete key uses, so it is one undo entry, one changelog batch, dependencies on the departed rows dropped and their ids retired either way. **Milestones are gates (0.78.0).** A milestone can wait on tasks, a task can wait on a milestone, and one milestone can wait on another: "EVT sign-off" waits on the EVT build and test, and production tooling waits on the sign-off. Make the link the way you make any other, in Dependency mode (click the diamond, or in the lines style the cap on the rule, as the first click or the second) or in the item editor, whose dependency list a milestone now has and whose "waits on" list offers milestones, marked with a diamond. Arrows run into and out of the diamond's points, and in the lines style onto the dashed rule level with the task at the other end. When the work before a gate slips, **Auto-schedule** moves the gate to the day that work ends and pushes the work after it along, starting on the gate's date at the earliest, the same rule as a task after a task. **Critical** carries a chain through a gate at no cost and lights the gate with it. The cycle check, copy and paste, the Table's Deps cell, the changelog, live editing and every import and export all take milestone links. Every milestone has an `id` for this, made from its name the way a task's is: the server gives one to every milestone without one when it starts, so an existing plan needs nothing done to it.@@ -440,16 +442,16 @@ with the edits if you choose to discard them. | Up / Down | Move the selected row in the table, following the visible order under sorts and filters. They never scroll the pane by themselves; it moves only enough to keep the selected row in view | | Up / Down in a list | Every list of the app's own walks with the arrows (0.69.0): the status pill's and the owner picker's on the chart, the phase, status, type and "waits on" lists in the editors, the Table's cells, and the Export and Import menus. Down and Up move the mark, Home and End jump to the ends, a letter jumps to the next entry starting with it, Enter takes the marked entry, Escape closes the list changing nothing, Tab closes it on the way to the next control | | Enter | Edit the selected row's name. Inside a cell, commit and step down one row |-| Delete | Delete the selected row, undoable. Only when a row is selected and no field is open, so Delete inside an editor just edits text. Backspace is deliberately not bound |+| Delete, Delete | Delete the selected rows, in the chart and the Table. The first press marks them in red and asks (the toast names one row, or counts a set); the second press, within four seconds, deletes them, and one Ctrl+Z brings them back. Any other key, a click or Esc withdraws the question. In the Table with nothing selected it acts on the focused row, and the focus then moves to the next row. Only when no field is open, so Delete inside an editor just edits text. Backspace is deliberately not bound | | Shift+D | Dependency mode: click the task or milestone that comes first, then the one that waits on it. A milestone is picked by its diamond, or in the lines style by the cap on its rule. The **Deps** button glows in the accent for as long as the mode is on, and its tooltip is the instruction. The row under the pointer lights up across both panes, tinted in its own phase colour, so you can see which task you are about to click. The armed first task wears the normal selection outline. Clicking a pair that already has a dependency removes it. Click an arrow to pick it, then Delete twice to remove that one. Stays on so you can chain them. Esc lets go of a picked arrow, then the pick, then leaves | | Shift+N | Open the New panel. Fires only when nothing else is going on, so a capital N typed into a field stays a capital N | | Ctrl+C | Copy the selected rows, tasks and milestones alike, to the app's own clipboard, ready for Ctrl+V. It ALSO puts their permanent IDs and names on the system clipboard, one per line, which is what it has always done and what an AI batch edit reads. Answers only when the app is idle and there is no live text selection on the page, so a Ctrl+C aimed at text you have highlighted, or typed into any field, is the browser's copy and never this | | Ctrl+V | Paste the copied rows as new rows. With a row selected they go DIRECTLY BELOW it (below the last of a set, in plan order) and into its lane; with nothing selected they go to the end of the lane you are looking at. Dependencies among the copied rows are remapped onto the copies, so a copied chain is a chain; dependencies pointing outside the set still name the originals, and nothing downstream of an original is changed. The whole paste is one Ctrl+Z | | Alt+Up / Alt+Down | Move a row one slot: the focused row in the table, the selected task in the chart. Crossing a lane boundary moves the row into that lane, and a lane that is collapsed OPENS the moment the moving row lands in it, so you can see what you are moving among. Under a column sort the row stays inside the run of rows the sort considers tied with it. The pane follows the row it is moving, keeping five rows visible ahead of it in the direction of travel, and both columns stay locked. With several rows selected the whole set moves as a group; if the selection has gaps in it, the FIRST press closes it up into one block at the topmost selected row and every press after that moves the block | | Tab / Shift+Tab | In the table, walk the selected row's fields. With no editor open it opens the first editable cell (Shift+Tab the last); with one open it commits and moves to the next. Wraps across rows and around the ends, and follows the visible order under a sort. With no row selected, Tab walks the page's own controls as usual. In the edit panels it is one keypress per field the same way, including the date fields, which Tab leaves whole instead of walking their day, month and year segments |-| Esc | Leave the field first: pressed with the caret in any text box it takes the focus back out of it, so the keyboard shortcuts work again on the very next keypress. It does not clear the box, and it does not do anything else in the same press. Fields that already have an Esc of their own keep it (a name editor cancels, a dropdown's type-in closes the list, a panel field closes the panel). With nothing focused it goes on as before: cancel the open field, then clear the row focus, then clear the selection |-| Ctrl+click | Toggle item in selection |-| Shift+click | Range select |+| Esc | Leave the field first: pressed with the caret in any text box it takes the focus back out of it, so the keyboard shortcuts work again on the very next keypress. It does not clear the box, and it does not do anything else in the same press. Fields that already have an Esc of their own keep it (a name editor cancels, a dropdown's type-in closes the list, a panel field closes the panel). With nothing focused it goes on as before: cancel the open field, then clear the row focus, then clear the selection. A row picked with its handle clears in one press |+| Ctrl+click | Toggle item in selection (on a row's handle, a bar, a name or a Table row) |+| Shift+click | Range select, over the rows on screen | The **focused row** is the row whose cell editor you touched last, marked with an accent bar down its left edge. It is not a separate thing to click: the edit you@@ -478,7 +480,9 @@ table, the item editor or the batch editor are taken exactly as typed. | Hover "today" | The current date | | Click a table cell | Edit owner, start, deadline, status or notes in place | | Click a table header | Sort by that column (deadline ascending by default) |-| Click bar or label | Select + open editor |+| Click a row's handle (left edge, on hover) | Select the row and nothing else; Ctrl adds or removes, Shift takes the range |+| Click a bar | Select + open the item editor |+| Click a name | Rename it in place | | Drag bar edge | Resize start or end date | | Drag bar body | Move bar (preserves duration) | | Drag vertically | Reorder rows |
SKILL.md+4−2@@ -274,20 +274,21 @@ rather than a category. - **Current in the legend** (0.70.0), between Owner and Overdue: the working set for a review. A row is **current** when it has STARTED (a task's start, a milestone's date: today or earlier) and is not complete or cancelled, so it is the work under way plus the work that should be (a task still "not started" after its start date shows). Future rows and finished ones are hidden. Counts tasks and milestones apart like Overdue (`data-tasks` / `data-ms`); `prefs.currentOnly`; derived on every render and at midnight, never stored. It ANDs with the other filters, so Current plus Overdue shows the overdue rows (every overdue row is current), and it applies to the table too. A month-only date counts from the first of its month; since 0.70.0 a month-only DEADLINE is due at the end of its month for Overdue (before, such a row could never be overdue). - **Owner and Overdue in the legend** (0.64.0), before the tag chips. **Owner** is a select listing every owner on the plan with their row count, then **Unassigned (n)** for the rows nobody owns, because on a young plan that is most of it and it has to be reachable. It is ONE choice for both lenses (`prefs.ownerFilter`, which replaces the table-only `prefs.tableOwner` and carries an old pick over), so the person picked on the chart is the person the table shows. Since 0.77.1 it is also one CONTROL, in the same place in the legend line in the chart and the table; the Table's own owner select at the far end of the line is gone, because the chart's seemed to vanish on the way into the Table. The legend keeps Owner, Current and Overdue in place when it rebuilds (0.77.1), so somebody else's change no longer closes the owner list in your hand. **Overdue** is a chip with a red dot and a count: a row is overdue when its deadline (a task's end, a milestone's date) is before today and its status does not settle it, complete or cancelled, read through `statusOf` so "completed", "done" and "canceled" settle it too; a blank status is still owed. It is the same rule as the Analytics Overdue card. Every overdue row carries a red edge on its label and a red ring on its bar (a red diamond on a milestone) whether or not the filter is on; clicking the chip shows only those rows (`prefs.overdueOnly`). The chip counts **tasks and milestones apart** (`Overdue 3 | ◆ 2`, also as `data-tasks` / `data-ms`), because milestones usually carry no status and would otherwise pile up as their dates pass and drown the late tasks; a milestone with no status counts as not reached until it is marked complete. **Overdue is derived, never stored:** it is worked out from the dates and statuses on every render, re-read after every edit, at midnight on a tab left open and when the tab comes back into view, and nothing is ever written to the plan, so no `Overdue` tag appears in the data, an export or the changelog, and computing it never makes another open tab stale. Both filters AND with the tags and narrow ROWS, never sections: under either, a section header shows only over rows it still has, its count reads "2 of 5", and Collapse all acts on the sections on screen. Readouts, Analytics and the exports stay whole-plan. An owner who no longer has a row drops out of the filter the way a vanished tag does - **Collapse all / Expand all** leads the legend (0.57.0), in the chart and the table. One press collapses every section to its header; when every section with rows is already collapsed it expands them all. A mixed state collapses. It is the same view state as the per-lane chevrons (`prefs.collapsed`), so it persists across a refresh, follows the chevrons (open the last lane by hand and it reads Collapse all again), and the table agrees with the chart. With everything collapsed the table says so rather than "no items match"+- **The row handle selects a row and opens nothing.** Every task and milestone row has a strip on its left edge (`.row-sel`, six dots, shown on hover and kept in the accent on a selected row): in the chart in the name column's left padding, in the Table at the start of the ID cell. A click selects that row only (never a toggle), Ctrl or Cmd+click adds or removes it, Shift+click takes the range from the anchor over the rows ON SCREEN (a folded section or a filter's hidden rows are skipped, in the chart as in the Table), and Escape clears it in one press. It opens no rename, picker, cell or panel (Ctrl and Shift on it do not open the batch panel), and it commits or closes whatever a click elsewhere would. Its press keeps the focus off it, so Delete twice, Ctrl+C, Alt+Arrow, Tab and the arrows answer on the next key. Dragged up or down it moves the row like the rest of the row; a sideways drag from it moves no dates. Hidden in Dependency mode. It is the same selection as every other, local to the tab (the presence highlight still shows it to others) and never saved - **Click a bar or a milestone** to open the **item editor** (0.65.0), a floating panel beside what you clicked with every field of the row: type, section, name, owner, status, start, deadline, effort, notes, provenance, tags, the label on the bar and the dependencies. It is the ONE editor for a task or a milestone: the pencil on a row, a double-click on a name, and the Table's Deps cell (which focuses "waits on") open the same panel. A single click on a name in the label column renames it in place instead (0.66.1). Everything in it, the dependencies included, is a draft that lands on Apply (or Enter) as one undo, and Cancel or Escape leaves the plan as it was. Through 0.64 a bar click opened a small dates-and-dependencies panel whose dependency changes landed at once, and a milestone click a name-date-description panel; both are gone - **Drag bar edges** to resize (changes start or end date). Every timeline drag snaps to the grid the axis is currently drawing: day boundaries at the day cell size, week starts at weeks, month, quarter and year firsts at the coarser ones. Resizing the start leaves the end where it is and the mirror for the end; dragging the bar body snaps the START and preserves the DURATION, so the end lands at start plus the same number of days. The preview snaps live, line to line, and is exactly what gets committed. The shortest bar a resize can produce is the first grid line past the fixed edge, so it is never zero or negative. Milestones snap the same way. Dates set explicitly (table cells, the item modal, the batch editor, the project-start shift) are taken exactly as given and are never rounded - **Sign in with Adom is required** for every page load and every change. Unauthenticated visitors get a sign-in page and no plan data. Sessions are an HttpOnly cookie, 30 days, and survive a restart. `--no-auth` turns it off for single-user local use - **Direct-loopback GETs of `/data`, `/state`, `/changelog` and `/export/*` need no session**, so an AI agent's read loop keeps working; everything that changes the plan needs a signed-in person from any origin. A request is "direct loopback" only if it comes from 127.0.0.1 with no `x-forwarded-*`/`cf-*` headers and a loopback `Host`, because the workspace proxy also arrives from 127.0.0.1 - **The changelog actor is the signed-in identity**, taken from the session and never from the request body, so a shared plan's history reads "Drew Owens moved X" - **New items default their provenance to "Added by <your display name>"**, still an ordinary editable field-- **Delete** removes the selected table row (undoable, redoable). Only when a row is selected and no editor, panel, search box or drag is live, so Delete inside a field only edits text. Backspace is not bound. Inert in the chart+- **Delete, then Delete again** removes the selected rows, in the chart and the Table alike. The first press marks them in red and the toast asks (naming one row, counting a set); the second press within four seconds deletes them through `deleteRows` (or `deleteRow` for the Table's one row), one undo entry, and any other key, a click or Esc withdraws the question. In the Table with nothing selected it acts on the focused row, and the focus then moves to the row that slid up into its place, so a cleanup run is Delete twice per row (until the row handle the Table deleted at the first press, so a second press out of the chart's habit took the next row too). Only when no editor, panel, search box or drag is live, so Delete inside a field only edits text. Backspace is not bound - **Who has it open (0.71.0).** With two or more tabs on the plan, the legend ends with each person's initials and "N tabs"; the tooltip names them, with each person's tab count. A tab checks in with the server once a minute (`POST /live/ping?cid=`) and whenever it comes back into view; a stream not heard from for 150 s (`ADOM_GANTT_LIVE_STALE_MS`) is closed and leaves the count, and a tab that reconnects replaces its old entry. Before this, connections a proxy kept open after their tabs closed were counted (one plan showed "30 here" for one person). A tab the server has forgotten (the laptop slept) reopens its stream on its next check-in. - **The Table's count says what it hides (0.71.0).** "333 of 415 items" has a tooltip listing why the rest are not shown: in collapsed sections, in sections switched off in the legend, by the legend filters (naming them: owner, tags, Current, Overdue), milestones switched off, the status filter, the search. Each hidden row counts once, under the first rule that hides it. - **Saving: field by field, the way Google Docs does it (0.77.0).** A tab sends what it CHANGED, as ops (`POST /ops`: `{op:'row', uid, set:{owner:'Ana'}, add:{deps:[..]}, del:{tags:[..]}}`, `{op:'add', row}`, `{op:'del', uid}`, `{op:'move', uid, after}`, `{op:'phases'|'reg'|'dates'|'cal', ...}`), one batch at a time, 400 ms after the last edit. The server applies each batch in arrival order onto the sidecar, makes one revision of it and announces it on the live stream (`GET /live`) to every open tab, the sender included, with the ops as stored. Each tab keeps a shadow of the server's plan; what it has not sent is the difference between the page and the shadow, so nothing has to be marked dirty. A remote op moves the shadow and, where the tab has no change of its own to that field, the page; where it has, its own value stays, because it reaches the server later and wins there too. So the last write wins PER FIELD; dependency and tag lists merge item by item; a move ("after that row") is replayed on the order as it stands; and every tab converges on exactly what the server holds. A gap in the revisions, a reconnect, or a save from something that does not send ops (an older tab's whole-state `/save`) makes the tab pull `/state` and apply it through the same rules. Ops are never refused for being stale (no 409); a bad field is skipped and named in `refused`. The same field changed by somebody else within two minutes of you setting it raises a notice naming them and your old value. An edit to a row somebody deleted, from a tab that had not seen the deletion, brings the row back (the 0.66.0 rule, now on the server). The red banner is for a save the server could not take at all (403, 401, unreachable; retried every 5 s), with Try again, Export my version and Reload latest. An import replaces the plan, so every other tab reloads with a toast saying who imported. The legend shows who else has the plan open (initials, the name on hover, "N tabs"), nothing when you are alone. The exported snapshot has no server and so no stream. Since 0.77.1 the server keeps its stored order grouped by section and says so (`grouped: true` on the order op), announces a batch's sections before its rows (a page that got a row moved into a section it did not have yet refused it, then sent the old section back), and makes no revision for moves that change nothing. A page compares orders grouped by section, so the server's list is never "corrected" for its grouping; a page that groups a grouped order differently from the server, or cannot apply an op, fetches the whole plan (at most every 10 s); and a catch-up rebuilds every row as the server has it, not only the overrides. - **Dependency mode** (the Deps button with the chart controls, or **Shift+D**): click the task or milestone that comes FIRST, then the one that waits on it, and the dependency is drawn. A milestone is picked by its diamond, or in the lines style by the cap on its rule, and armed it wears the accent ring on both (0.78.0). **The Deps button glows** in the accent, breathing on the same 2.6s the critical path does, for exactly as long as the mode is on, and its tooltip carries the instruction in and out of the mode: "Adding dependencies. Click the task that comes first, then the one that waits on it. Click an arrow to pick it. Esc leaves the mode." There is no info bar under the legend any more, so entering and leaving the mode moves nothing on the page. **Creating a row leaves the mode first**: Shift+N and the New button both exit it (armed source dropped, wash cleared, picked arrow released, glow off) and then open the create panel, so you never end up typing a name over a chart that is still armed for a pairing. **The row under the pointer lights up**, name cell and chart band together, washed in that row's own phase colour, so a 20px bar and the name a pane away read as one row and you can see which task you are about to click. One row at a time, and it goes the instant the mode does. The armed first task wears the normal selection outline so you can see what the pair is waiting on. The pair resets and the mode stays on, so a run of dependencies is one sitting. Clicking a pair that ALREADY has a dependency REMOVES it, which is the same removal as the editor's ×: one undo entry, one changelog event. Refuses anything that would make a cycle, naming the shortest loop path. Since 0.78.0 milestones are endpoints like tasks: until then a dep was stored as a task id and a milestone had none, so the mode refused them. **Clicking an arrow picks it**, in this mode only, and **Delete** twice removes the dependency it stands for (the first press arms and says so, the second commits; any other key, a click or Esc withdraws the question). Outside the mode the arrows take no clicks at all. Esc lets go of a picked arrow, then a half-made pick, then leaves the mode. While the mode is on, bar clicks mean dependencies: no editor, no drag, no reorder, and **no resize edges**. The two 8px drag handles are hidden outright in the mode, out of paint and out of hit-testing together, so the whole face of a bar is one clean pick target: on a one-day bar, which the app draws at a 6px floor, the handles covered the entire bar twice over and every aim at it returned a resize cursor instead of the bar. Leaving the mode restores them exactly, by any of the ways out. Milestones never had an edge handle, so the mode leaves their hit strip and its pointer cursor untouched. Ctrl+Z undoes the last add or removal without leaving the mode - **The batch panel's Delete** removes the WHOLE selection, milestones included, in one press. It is the compact panels' danger button in the compact panels' place: a muted ghost, right-aligned by `margin-left:auto` so it is clear of Apply and Cancel, red on hover, last in the panel's Tab walk. It lands on the same `deleteRows` the chart's armed Delete key and the table's Delete use, so it is one `multi` undo entry restoring every row where it stood, one changelog batch, dependencies on the departed rows dropped and their uids retired, whichever hand did it; only the `via` differs (`batch-delete` against `chart-delete`). One press and no arming, unlike the key: the key acts on a selection that may be entirely off screen and asks twice, where a button inside the panel that IS the selection made visible has already been aimed. The panel's headline counts the TASKS the date fields will move and says how many milestones it is leaving alone; the Delete's tooltip names the total it will actually remove, so the two counts cannot be read as one - **Copy IDs**: with rows selected, the table's tools row and the chart's selection chip (and the batch panel) copy the selected items' stable ids, one per line as `id<TAB>name`, for pasting into an AI chat to request batch edits. Ctrl+C does the same when nothing is focused and no text is selected. If the clipboard is refused the ids appear in a selectable box rather than failing silently-- **Row multi-select in the table**: ctrl-click toggles a row, shift-click takes the range from the anchor following the VISIBLE order under sorts and filters. It is the SAME selection as the chart's, so rows picked in one lens are selected in the other. Modified clicks never open a cell editor and never start a native text selection; unmodified click-drag still selects cell text. Alt+Up/Down moves the whole set (plan order only; refused under a sort), and Delete removes the whole set as one undo. A selection with GAPS in it groups itself up on the first Alt+Arrow press rather than moving: the selected rows close up into one contiguous block at the topmost one's slot+- **Row multi-select in the table**: ctrl-click (anywhere on the row, or on its handle) toggles a row, shift-click takes the range from the anchor following the VISIBLE order under sorts and filters. It is the SAME selection as the chart's, so rows picked in one lens are selected in the other. Modified clicks never open a cell editor and never start a native text selection; unmodified click-drag still selects cell text. Alt+Up/Down moves the whole set (plan order only; refused under a sort), and Delete removes the whole set as one undo. A selection with GAPS in it groups itself up on the first Alt+Arrow press rather than moving: the selected rows close up into one contiguous block at the topmost one's slot - **Arrows are coloured by where they come FROM.** Each dependency arrow, line and head together, takes the phase colour of the task at its TAIL, so the origin of a chain is readable without tracing it back and two arrows into the same row say which lane each came out of. A lane colour is chosen to work as a 20px bar and can vanish as a 1.5px line, so the stroke is derived rather than copied: hue and saturation are held and lightness is walked away from the page in 2% steps until the stroke clears a 3:1 contrast ratio against the background, which is the WCAG minimum for a graphical object. A lane painted navy therefore gets a lighter blue arrow on the dark theme and is left alone on the light one, and it still reads as that lane rather than reverting to a neutral. Recomputed on a theme change, a lane repaint and a row moving lane. The amber of a picked arrow, the red of one armed for deletion and the white of the critical path all still override it while they are active - **Arrows steer clear of the bars.** A dependency leaves the source bar's END sideways, drops, and arrives at the next bar's START sideways again, with rounded elbows, so which end is the cause is always readable. It routes AROUND the bars in between rather than through them: the drop moves to the nearest clear channel, and a long or backward route runs along the empty lane between two rows rather than across a task that has nothing to do with it. Where a bar lies clean across the only gap, the arrow goes round the end of it. Nothing to configure; it is re-decided on every redraw, including live during a drag - **Deps column** in the table: a narrow read-only indicator, needs and blocks counts with the names on hover, blank when there are none. Clicking it opens the editor where dependencies are changed. Sortable by total count@@ -422,6 +423,7 @@ entered the same way: | A date | The browser's date field, everywhere (the header's start picker went in 0.75.0; the project dates are on Setup) | | Sections and the tag list | Edited in place on the Setup page: click a name to rename, drag a row to move it | | Many rows at once | The batch panel (Ctrl+click): dates shift on Apply; tags and effort act at once, one undo each |+| Picking rows, nothing else | The handle on a row's left edge, in the chart and the Table: a click selects that row, Ctrl+click adds or removes, Shift+click takes the range. It opens no field and no panel. Delete twice then deletes them | ## The table view
client-vs-server/README.md+1−1@@ -24,7 +24,7 @@ Gone when you reload, and not shared even with your other tabs: | What | Notes | |---|---| | The section, Milestone and Dependencies chips in the legend | Hiding or dimming a section is a quick look, not a setting |-| Your selection, the open editor, your scroll position | Your selection is also shown to others as a highlight (see below), but it is never saved |+| Your selection, the open editor, your scroll position | However you picked the rows (a row's handle, Ctrl+click, Shift+click), the selection is this tab's own. It is shown to others as a highlight (see below), but it is never saved | | Undo history | Ctrl+Z undoes this tab's own edits only, and never a teammate's later change to the same field | | "See what the team sees" | The owner's preview of the team view lasts until the tab closes |
docs/BUILD-NOTES.md+86@@ -3,6 +3,92 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. The release checklist is [RELEASING.md](RELEASING.md). +## Build notes, 2026-11-03 (0.78.2, a handle that selects a row)++**Fixed first: links made in Dependency mode were lost (0.77.0 to 0.78.1).** Adding a+dependency in the mode to a row that already had one, or taking one out, said "saved" and+never reached the server, so a reload lost it. The page's copy of what the server holds took+the row's own dependency list (`rowOv`: `o.deps=r.deps||[]`), and the mode changes that list+in place, so the copy changed with it and the page found nothing to send. The copy now takes+a list of its own, as the tags always did; nothing else in `rowOv` or `rowDiff` shares a list+or an object with the row. Found by the subtasks work. The milestone dependencies flow now adds+a second link, reloads, takes it out and reloads, checking the page and the server each time:+it fails three checks on the old line and passes 39 on the new one. Collaboration docs,+sections and fuzz 36/0 with it.++Drew: every row needs a blank place that selects it without opening anything, so a row can+go with Delete twice instead of the editor's Delete button. In the chart a click on the name+renames, each pill opens its picker, the link button copies and the bar opens the item+editor; in the Table every cell opens its editor. The one quiet spot was the gap after the+pills, and a long name leaves no gap.++**What was there.** The selection is one set of uids for both lenses (`selected`,+`selAnchor`), shown by `.multi-sel` and counted by the `#selCount` chip. On the chart Delete+was already armed (the first press marks the selection and asks, the second deletes it,+one undo), but it refuses while the item editor is open on one row, and a plain click on a+bar opens that editor. In the Table Delete deleted at the first press, the focused row+rather than the selection when one row was picked, and moved the focus on, so Delete+twice took two rows.++**The design.** A handle on the left edge of every task and milestone row (`.row-sel`), in+the chart's name column (inside the row's existing 12px padding, so no name moves) and at+the start of the Table's ID cell (the column is 8px wider). Six dots, shown on hover and kept+in the accent on a selected row: the handle ClickUp, Notion and Asana show at the start of+a row, so it is where people look, and at rest the plan looks as it did. A click on it+follows a spreadsheet row header: this row only (never a toggle), Ctrl or Cmd adds or+removes, Shift takes the range over the rows on screen. Not a checkbox: a box at the start+of a task reads as "mark it done", and a checkbox adds to the set where a plain click here+replaces it. Like the others' handles it drags the row, up and down only. It opens nothing+(Ctrl and Shift on it do not open the batch panel), and it commits or closes whatever a+click elsewhere would. Its mousedown is prevented, so the focus stays off it and the next+key is the page's.++**Changed on the way.**++1. *The Table's Delete is armed like the chart's*, so Delete twice is one thing in both+ lenses: the selection when there is one, the focused row otherwise; the focus then moves+ to the row that slid up, so a cleanup run is Delete twice per row. The question names a+ single row ("Press Delete again to delete "Bravo one""), because the Table's cursor and+ its selection can be different rows, and armed Table rows are marked red like the chart's.+2. *A Shift range skips rows a fold or a filter hides* (`selKeys`), in the chart as the+ Table already did. Before, a range across a collapsed section took its hidden rows, which+ Delete twice would then remove unseen.+3. *One Escape clears a row the handle picked in the Table.* The focus rung used to drop+ only the cursor first, leaving the row selected.+4. *"Deleted 1 rows"* now names the row.++**Selection stays local.** It is the same in-memory set as before, never saved or sent as+an edit; the existing presence highlight still shows others which rows you have selected.+client-vs-server/README.md names the handle in its "Only this tab" row; the diagram already+says "What you have selected", so it is not re-rendered.++**Subtasks (branch subtasks).** A three-way merge into a copy of that branch conflicts in+four places in gantt.html, all both-sides additions except `armDelete`, where subtasks+widens the victims to the subtree: the merged version keeps its `namedUids` for the second+press and marks the whole subtree red in the Table too. Its chevron's slot started 5px inside+the row padding (`.sub-tog` margin-left -5px), so 5px of it lay under the handle's strip;+this branch sets `.label-row .sub-tog { margin-left: 0 }` (inert until subtasks lands), so+after the merge the chevron starts where the handle ends and a nested plan's names sit 5px+further right. A lab run of the merged copy (a parent with two levels under it, chart and+Table): the handle selects parents, subtasks and milestones, Ctrl adds, Delete twice on a+parent asks about "4 rows (3 subtasks among them)" and takes the subtree, one Ctrl+Z puts it+back with its parents, and the Table's handle sits in the ID cell, apart from the tree in the+name cell.++**Tests.** `ui_test` flow "row select" (chart and Table: hover, click, Ctrl, Shift, a folded+section, Escape, Delete twice on one row and on a set, Escape withdrawing the question,+rename, both pills, the link button, the bar's editor, dragging from the handle up and down+and sideways, Alt+Down, the arrows, Tab and a cell click right after it, the Table's held+drag), manifest entries `.row-sel` in `#labels` and in `#tableBody` (safe, clicked by the+walk). Under the suite lock, on this branch with main's 0.78.1 merged: `ui_test --only "row+select"` 47/0; `--only coverage,editor,live,row links,viewport` 278/0 (the walk clicked both+handles among 521 clicks, no page error); `collab_test --only docs,sections` 31/0 (docs 20,+sections 11). Lab screenshots at 100% and 120% text size, dark and light, chart and Table.++**Found on the way.** Headless Chrome reports `hover: none` and `pointer: none` even with the+suite's CDP media emulation, so a plain `(hover: none)` touch fallback showed every handle in+every test and would have had the coverage walk clicking hundreds of them; the fallback asks+for a coarse pointer as well, which a real touch screen reports. ## Build notes, 2026-11-03 (0.78.1, touch only what changed) Measured before anything was changed, and after each change: a copy of the team plan as it
gantt.html+199−50@@ -1166,6 +1166,10 @@ body.tbl-picking, body.tbl-picking .data-table { user-select: none; } body.tbl-picking .cell-input { user-select: text; } .data-table tbody tr.row-focus > td:first-child { box-shadow: inset 3px 0 0 var(--accent); } .data-table tbody tr.row-focus > td { background: rgba(0,184,177,0.045); }+/* Delete pressed once: the rows the second press would take, the chart's armed red, over+ the selection and focus tints. */+.data-table tbody tr.del-armed > td, .data-table tbody tr.del-armed:hover > td { background: rgba(248,81,73,0.13); }+.data-table tbody tr.del-armed > td:first-child { box-shadow: inset 2px 0 0 #f85149; } /* Plan order is the only order you can drag in, so say so rather than letting the row silently refuse. */ .data-table tbody tr.no-drag { cursor: default; }@@ -1475,7 +1479,38 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .row-link:hover { color: var(--accent); border-color: var(--accent); background: var(--surface); } .row-link.copied, .uid-link.copied { color: var(--accent); border-color: var(--accent); } .label-row .row-edit { right: 28px; }-.data-table td.c-uid { white-space: nowrap; padding-left: 8px; padding-right: 4px; }+/* The row handle: a slim strip on the left edge of every task and milestone row, in the+ chart's name column and the Table's ID cell. A click on it SELECTS the row and does+ nothing else (no rename, no picker, no editor), so Delete twice, Ctrl+C, Alt+Arrow and+ the batch keys have a row to act on. Ctrl or Cmd adds or drops it, Shift takes the+ range. Its six dots are the handle ClickUp, Notion and Asana put at the start of a row,+ and like theirs it drags the row too. Hidden until the row is hovered, so a plan at rest+ looks as it did; a selected row keeps its handle, in the accent. It sits in the row's+ existing left padding, so no name moves. Not a checkbox, on purpose: a box at the start+ of a task reads as "mark it done", and a checkbox adds to the set where this click+ replaces it. */+.row-sel { position: absolute; left: 0; top: 0; bottom: 0; width: 12px; display: flex; align-items: center; justify-content: center; padding: 0; margin: 0; background: none; border: none; border-radius: 0 4px 4px 0; color: var(--text3); cursor: pointer; opacity: 0; z-index: 11; transition: opacity 0.12s, color 0.12s, background-color 0.12s; }+.row-sel::before { content: ''; width: 6px; height: 10px; flex: none; background-color: currentColor;+ -webkit-mask: var(--grip-dots) center / 6px 10px no-repeat; mask: var(--grip-dots) center / 6px 10px no-repeat; }+/* The six dots as a mask on one pseudo-element rather than an SVG in every row: a plan has+ hundreds of rows, and the Table rebuilds each row's markup on every live change. */+:root { --grip-dots: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 6 10'%3E%3Cg fill='%23000'%3E%3Ccircle cx='1' cy='1' r='1'/%3E%3Ccircle cx='5' cy='1' r='1'/%3E%3Ccircle cx='1' cy='5' r='1'/%3E%3Ccircle cx='5' cy='5' r='1'/%3E%3Ccircle cx='1' cy='9' r='1'/%3E%3Ccircle cx='5' cy='9' r='1'/%3E%3C/g%3E%3C/svg%3E"); }+.label-row:hover .row-sel, .data-table tbody tr:hover .row-sel, .row-sel:focus-visible { opacity: 1; }+.row-sel:hover { color: var(--accent); background-color: color-mix(in srgb, var(--accent) 16%, transparent); }+.label-row.multi-sel .row-sel, .data-table tbody tr.multi-sel .row-sel { opacity: 1; color: var(--accent); }+/* Dependency mode owns every click on the chart, and hides the bars' handles too. */+body.dep-mode .row-sel { display: none; }+/* No hover on a touch screen, so the handle stays faintly in view there. Both features,+ because a headless browser also reports no hover (with no pointer at all). */+@media (hover: none) and (pointer: coarse) { .row-sel { opacity: 0.5; } }+/* The subtasks chevron (branch subtasks) starts where the row's padding ends, beside the+ handle's strip rather than 5px into it, so the two never share a pixel to click. */+.label-row .sub-tog { margin-left: 0; }+@media print { .row-sel { display: none !important; } }+.data-table td.c-uid { position: relative; white-space: nowrap; padding-left: 16px; padding-right: 4px; }+/* Under the sticky header (z-index 2) as the rows scroll beneath it: a Table cell is no+ stacking context of its own, unlike a chart row. */+.data-table .row-sel { width: 14px; z-index: 1; } .data-table .uid-link { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px solid transparent; border-radius: 999px; padding: 1px 6px; font-family: 'JetBrains Mono', monospace; font-size: calc(10px * var(--fs)); color: var(--text2); cursor: pointer; } .data-table .uid-link svg { width: 11px; height: 11px; opacity: 0.7; } .data-table .uid-link:hover { color: var(--accent); border-color: var(--accent); }@@ -4304,13 +4339,14 @@ document.addEventListener('keydown',e=>{ moveRowFocus(e.key==='ArrowDown'?1:-1); return; }- // Delete removes the focused row, the same path the row's own Delete button- // takes, so it is one undo, one toast naming the row, one changelog event and one- // sidecar deletion signal. Focus moves to the row that slid up into its place so- // a cleanup run is Delete, Delete, Delete. Backspace is deliberately NOT bound:- // it is a fat-finger away from Delete and browsers spent a decade navigating back- // on it. Table only, and only when a row is selected and no field is live, which- // is the same gate the arrows use, so Delete inside an editor only edits text.+ // Delete, then Delete again, removes the selected rows, in both lenses: the first press+ // marks them and says what would go, the second does it (chartDeleteKey). In the Table,+ // with nothing selected, it is the focused row's. The row's own Delete button takes the+ // same path, so it is one undo, one toast, one changelog event and one sidecar deletion+ // signal. Focus moves to the row that slid up into its place, so a cleanup run is+ // Delete twice, Delete twice. Backspace is deliberately NOT bound: it is a fat-finger+ // away from Delete and browsers spent a decade navigating back on it. Only when no+ // field is live, the same gate the arrows use, so Delete inside an editor edits text. if(e.key==='Delete'&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey){ // A picked ARROW answers first of all: it is the smallest thing on screen the key // could mean, and while one is picked the mode has already taken the selection@@ -4321,27 +4357,7 @@ document.addEventListener('keydown',e=>{ // see the key first or tableNavReady would return and eat it. if(chartDeleteReady()){ chartDeleteKey(e); return; } if(!tableNavReady())return;- const rows=visibleRowEls();- const i=rows.findIndex(t=>t.dataset.key===focusedKey);- // A multi-selection goes in one keystroke and comes back in one Ctrl+Z, the way- // a file manager behaves. Deleted bottom-up so each recorded index is still- // valid when the next one is removed.- if(selected.size>1){- const victims=selectedRows();- if(!victims.length)return;- e.preventDefault();- deleteRows(victims,'table-delete');- focusRowAt(Math.min(i<0?0:i,visibleRowEls().length-1));- return;- }- if(i<0)return; // nothing selected, nothing to delete- const r=rowItem(focusedKey);- if(!r)return;- e.preventDefault();- deleteRow(r);- // The list is one shorter now, so the same index is the row that moved up, and- // the clamp inside focusRowAt lands on the last row when the last one went.- focusRowAt(Math.min(i,visibleRowEls().length-1));+ tableDeleteKey(e); return; } // Tab with a row selected but NO editor open used to fall straight through to the@@ -4455,8 +4471,12 @@ document.addEventListener('keydown',e=>{ // that was meant for the chart's selection. // Skipped while a set is selected: the focused row is one OF the selection, so // dropping it on its own would look like the key did nothing while two rows stayed- // lit. With a set up, Escape takes the set and the focus together.- if(focusedKey&&prefs.view==='table'&&selected.size<2){ setFocusedRow(null); return; }+ // lit. With a set up, Escape takes the set and the focus together. The same when the+ // focused row IS the one selected row, which is what the row handle and a Ctrl+click+ // leave: one Escape clears what they picked.+ const fr=focusedKey?rowItem(focusedKey):null;+ const focusPicked=!!(fr&&fr.uid&&selected.has(fr.uid));+ if(focusedKey&&prefs.view==='table'&&selected.size<2&&!focusPicked){ setFocusedRow(null); return; } clearSelection(); setFocusedRow(null); closeMenus(); }); }@@ -4516,8 +4536,11 @@ const dataUids=new Set(); // A row as the sidecar has always stored it. The server keeps exactly this per row. function rowOv(r){ const o={uid:r.uid,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r),name:r.name,phase:r.phase};- // Dependencies on every row: a milestone waits on work too since 0.78.0.- o.deps=r.deps||[];+ // Dependencies on every row: a milestone waits on work too since 0.78.0. A list of its own+ // (0.78.2), as the tags have: Dependency mode adds to and removes from the row's list in+ // place, and a copy that shared it changed with it, so the page found nothing to send and+ // said "saved" for a link the server never got. Every version from 0.77.0 lost them so.+ o.deps=(r.deps||[]).slice(); if(r.type==='milestone')o.date=r.date; else { o.s=r.start; o.e=r.end; o.note=r.note||''; } // A row's own tags (0.60.0 edits them). Written only when the row has a list, so a@@ -6764,6 +6787,9 @@ function buildRowDom(r,idx){ bDiv.className='bar-row';const x1=toX(r.start),x2=toX(r.end),b=document.createElement('div');b.className='bar'+(r.striped?' bar-striped':'');b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';b.style.background=phaseColor(r.phase);b.dataset.title=r.name;b.dataset.dates=fmt(r.start)+' → '+fmt(r.end);b.dataset.body=r.description||'';b.dataset.prov=provLabel(r);b.dataset.tags=ownTags(r).join(', ');b.dataset.id=r.id; const nt=document.createElement('div');nt.className='bar-note';nt.textContent=r.note||'';b.appendChild(nt);r._noteEl=nt; b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle left'}));b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle right'}));bDiv.appendChild(b);barEls[r.id]={bar:b,idx,row:bDiv};}+ // The handle that selects the row and nothing else (see selectFromHandle). A lane header+ // cannot be selected, so it has none.+ if(r.type!=='group')lDiv.appendChild(makeSelBtn()); r._lDiv=lDiv;r._bDiv=bDiv;r._itemIdx=idx;labelsEl.appendChild(lDiv);bars.appendChild(bDiv); // Last, because everything it marks has to exist first: both row divs are assigned on // the line above and the bar went into barEls a few lines before that. Here rather@@ -7873,7 +7899,12 @@ let selAnchor = null; // delete, move as a block) means something different for a lane than for the // rows inside it. function selUid(r){ return r&&r.type!=='group'?r.uid:null; }-function selKeys(){return items.filter(r=>selUid(r)).map(r=>r.uid);}+// The rows a Shift+click range walks, in plan order: the ones ON SCREEN, the way a file+// manager and the Table's own Shift+click take a range. A row folded away in a collapsed+// section or hidden by a filter is not between two rows anyone can see, and taking it+// silently would hand it to Delete twice, Alt+Arrow and the batch panel unseen. The two+// ends of the range are counted whatever they are.+function selKeys(...ends){return items.filter(r=>selUid(r)&&(!r._collapsed||ends.includes(r.uid))).map(r=>r.uid);} function rowByUid(u){return items.find(r=>r.uid===u)||null;} function clearSelection(){selected.clear();refreshSelVisuals();updateSelCount();} function refreshSelVisuals(){@@ -7893,7 +7924,7 @@ function doSelect(uid, e){ if(!uid)return; const ctrl=e.ctrlKey||e.metaKey, shift=e.shiftKey; if(shift&&selAnchor){- const ids=selKeys(), ai=ids.indexOf(selAnchor), ci=ids.indexOf(uid);+ const ids=selKeys(selAnchor,uid), ai=ids.indexOf(selAnchor), ci=ids.indexOf(uid); if(ai>=0&&ci>=0){ const lo=Math.min(ai,ci), hi=Math.max(ai,ci); if(!ctrl)selected.clear();@@ -7934,6 +7965,45 @@ function selectOnly(r){ if(k)setFocusedRow(k); } }+// ── The row handle ──+//+// Drew: there was nowhere to click that just selects a row. In the chart a click on the+// name renames it, each pill opens its picker, the link button copies and the bar opens+// the item editor; in the Table every cell opens its editor. The one quiet spot was the+// gap after the pills, and a long name leaves no gap. So every task and milestone row+// has a handle on its left edge (styles at .row-sel), and a click on it SELECTS, by the+// rules of a spreadsheet's row header and a file manager:+//+// click this row, and only this row. Never a toggle: clicking a selected row+// keeps it, so a double-click cannot undo itself+// Ctrl/Cmd+click adds the row to the set, or takes it out+// Shift+click every row on screen from the anchor to this one+//+// The same selection as everywhere else, so Delete twice, Ctrl+C and V, Alt+Arrow and+// Copy IDs act on it, and Escape clears it. It opens NOTHING: Ctrl and Shift do not open+// the batch panel the way they do on a bar. What a click anywhere else would close, it+// closes: a rename or a cell being edited commits, a picker lets go, and a panel that was+// open (about the old selection) goes.+//+// Its mousedown is prevented (with the drag arms), so the focus never lands on the handle+// and the keyboard is the page's on the very next key: Delete, Tab, Enter, the arrows.+// That is also why the field that had the focus is let go of here, by hand. In the chart+// the plain click is selectOnly, so the Table's cursor comes along and the two lenses+// agree about the current row, as they do after a rename.+// The dots are drawn by CSS (.row-sel::before), so the markup is one empty button.+const ROW_SEL_HTML='<button type="button" class="row-sel" tabindex="-1" aria-label="Select this row" title="Select this row. Ctrl+click adds or removes it, Shift+click selects the rows in between. Drag to move it."></button>';+function makeSelBtn(){ const t=document.createElement('template'); t.innerHTML=ROW_SEL_HTML; return t.content.firstElementChild; }+function selectFromHandle(r,e,lens){+ if(!selUid(r))return;+ const k=rowKey(r);+ closeLabelEditors(true);+ const ae=document.activeElement;+ if(ae&&ae!==document.body&&ae!==document.documentElement&&typeof ae.blur==='function'){ try{ ae.blur(); }catch(_){} }+ closeMenus(); closeEditor(); hideTip();+ if(lens==='table'){ tableSelect(k,e); return; }+ if(e.ctrlKey||e.metaKey||e.shiftKey)doSelect(r.uid,e);+ else selectOnly(r);+} // The rows the selection names, in plan order, which is the order every operation // on a set walks them in. function selectedRows(){return items.filter(r=>r.uid&&selected.has(r.uid));}@@ -8266,6 +8336,16 @@ async function pasteRows(){ function wireLabelClicks(){ labelsEl.addEventListener('click',e=>{ if(reorderState||laneDrag)return;+ // The row handle selects the row and does nothing else (selectFromHandle). Stopped here,+ // so the document's "a click outside closes the editor" handler leaves the closing to it.+ const handle=e.target.closest('.row-sel');+ if(handle){+ e.stopPropagation(); e.preventDefault();+ if(Date.now()-dragEndedAt<150)return; // the release of a drag made from it+ const hr=items.find(x=>x._lDiv===handle.closest('.label-row'));+ if(hr)selectFromHandle(hr,e,'gantt');+ return;+ } // A drag that ends over a row produces a click on it, and this used to answer that // click by opening the name editor on whatever the pointer happened to land on. // Timestamped rather than a sticky flag, for the reason 0.12.0 recorded: a drag@@ -8350,6 +8430,8 @@ labelsEl.addEventListener('click',e=>{ // whatever has been typed so far, so the fast path is never paid for by the // slow one: the inline editor is already mounted and instant. labelsEl.addEventListener('dblclick',e=>{+ // Two clicks on the handle are two selections of the same row, not an editor.+ if(e.target.closest('.row-sel'))return; const row=e.target.closest('.label-row[data-id], .label-row.ms'); if(!row)return; const r=items.find(x=>x._lDiv===row);@@ -8514,6 +8596,11 @@ labelsEl.addEventListener('mousedown',e=>{ if(e.target.closest('.status-pill, .owner-pill'))return; // editing a pill, not starting a drag const row=e.target.closest('.label-row[data-id], .label-row.ms'); if(!row)return;+ // The row handle drags the row, up and down only: a handle moves a row, it does not+ // drag dates along a time axis that is not there. Its press keeps the focus where it+ // was and starts no text selection (selectFromHandle says why).+ const grip=!!e.target.closest('.row-sel');+ if(grip)e.preventDefault(); // A milestone label row carries no data-id, so it could never be armed and a // milestone was the one row in the plan that could not be dragged into another lane // from the name column. It arms by uid instead, the same key its diamond uses, and@@ -8521,10 +8608,10 @@ labelsEl.addEventListener('mousedown',e=>{ if(!row.dataset.id){ const mr=items.find(x=>x._lDiv===row); if(!mr||!mr.uid)return;- gesture={uid:mr.uid,startX:e.clientX,startY:e.clientY,src:'mslabel'};+ gesture={uid:mr.uid,startX:e.clientX,startY:e.clientY,src:'mslabel',grip}; return; }- gesture={id:row.dataset.id,startX:e.clientX,startY:e.clientY,src:'label'};+ gesture={id:row.dataset.id,startX:e.clientX,startY:e.clientY,src:'label',grip}; }); } @@ -9998,6 +10085,9 @@ document.addEventListener('mousemove',e=>{ if(gesture&&!dragState&&!reorderState){ const dx=e.clientX-gesture.startX, dy=e.clientY-gesture.startY; if(Math.abs(dx)<5&&Math.abs(dy)<5)return;+ // From the row handle only a move up or down is a drag (the row's); sideways travel+ // alone leaves the press a click, which selects.+ if(gesture.grip&&Math.abs(dy)<5)return; hideTip(); const id=gesture.id; // Bars are keyed by id, milestones by uid, and everything past this point wants@@ -10029,7 +10119,7 @@ document.addEventListener('mousemove',e=>{ return; } - if(Math.abs(dx)>=Math.abs(dy)){+ if(Math.abs(dx)>=Math.abs(dy)&&!gesture.grip){ // Horizontal → move bar dates const bar=gesture.bar||barEls[id]?.bar; if(!bar){gesture=null;return;}@@ -10774,7 +10864,8 @@ $('btnHelp').addEventListener('click',e=>{ <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click bar</span>Edit dates + deps</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+click</span>Toggle in selection</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+click</span>Range select</div>- <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click label</span>Select (same as bar)</div>+ <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">row handle</span>The dots on a row's left edge: select the row and nothing else</div>+ <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click name</span>Rename it in place</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag edge</span>Resize start or end</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag horiz.</span>Move bar dates</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag vert.</span>Reorder rows</div>@@ -10790,7 +10881,7 @@ $('btnHelp').addEventListener('click',e=>{ <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+Shift+Z</span>Redo, also Ctrl+Y. Any new edit clears it</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Up/Down</span>Move the selected row in the table. The pane only scrolls to keep it in view</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Enter</span>Edit the selected row's name. In a cell, commit and step down a row</div>- <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Delete</span>Delete the selected row. Undoable. On the chart it takes two presses: the first says what would go and marks it, the second does it</div>+ <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Delete</span>Delete the selected rows, in two presses: the first says what would go and marks it, the second does it. Undoable</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+D</span>Add dependencies: click the task or milestone that comes first, then the one that waits on it</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+N</span>New task or milestone</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+C</span>Copy the selected rows. Also puts their IDs on the system clipboard for an AI chat</div>@@ -12799,7 +12890,7 @@ function tableRowHtml(r){ const ed=editable?' editable':''; const trCls=[ms?'is-ms':'',isCancelled(r)?'cancelled':''].filter(Boolean).join(' '); return `<tr data-key="${esc(rowKey(r))}"${trCls?` class="${trCls}"`:''}>`- +`<td class="c-uid">${r.uid?`<button class="uid-link" type="button" tabindex="-1" title="Copy a link to this row, to paste in Chat">#${esc(r.uid)}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg></button>`:''}</td>`+ +`<td class="c-uid">${r.uid?ROW_SEL_HTML:''}${r.uid?`<button class="uid-link" type="button" tabindex="-1" title="Copy a link to this row, to paste in Chat">#${esc(r.uid)}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg></button>`:''}</td>` +`<td class="c-name${ed}" data-field="name"><span class="trunc" data-title="${esc(r.name)}" data-dates="${esc(dates)}" data-body="${esc(r.description||'')}">` +`${ms?'<span class="ms-mark">◆</span>':''}${esc(r.name)}</span></td>` +`<td class="c-phase${ed}" data-field="phase"${phaseTagsOf(r).length?` title="Tags: ${esc(phaseTagsOf(r).join(', '))}"`:''}>${dot}${esc(phaseNameOf(r))}</td>`@@ -12953,7 +13044,7 @@ function fixedCols(){ return prefs.tableCols&&Object.keys(prefs.tableCols).lengt // width, and the table is pixel-driven from then on. That is also what Excel does: // before you touch anything the columns are whatever the sheet says, and the moment // you drag one they are numbers you own.-const COL_DEFAULTS={uid:'74px',name:'20%',phase:'9%',owner:'8%',start:'7%',deadline:'7%',status:'8%',effort:'5%',deps:'5%',notes:'13%',tags:'8%',prov:'10%',del:'30px'};+const COL_DEFAULTS={uid:'82px',name:'20%',phase:'9%',owner:'8%',start:'7%',deadline:'7%',status:'8%',effort:'5%',deps:'5%',notes:'13%',tags:'8%',prov:'10%',del:'30px'}; function applyColWidths(){ const table=tableHead.closest('table'); if(!table)return;@@ -13125,6 +13216,9 @@ function rowDragMove(e){ function wireTableRowDrag(){ tableBody.addEventListener('mousedown',e=>{ if(e.button!==0)return;+ // The row handle keeps the focus where it was and starts no text selection; a press+ // held on it still lifts the row, the same as anywhere else on the row.+ if(e.target.closest('.row-sel'))e.preventDefault(); // A modified click is a selection gesture, so the browser must not treat it as a // text-selection anchor. This is the blue swath: with no row multi-select, ctrl // and shift clicks fell through to the native anchor-and-extend machinery and@@ -13174,6 +13268,7 @@ tableBody.addEventListener('click',e=>{ let focusedKey=null; function markSelectedRows(){ if(!tableBody)return;+ markArmedRows(); tableBody.querySelectorAll('tr.multi-sel').forEach(t=>t.classList.remove('multi-sel')); if(!selected.size)return; tableBody.querySelectorAll('tr[data-key]').forEach(t=>{@@ -13931,6 +14026,16 @@ tableHead.addEventListener('click',e=>{ renderTable(); }); tableBody.addEventListener('click',e=>{+ // The row handle selects the row and opens no cell (selectFromHandle). Ctrl and Shift on+ // it are the Table's own multi-select, over the rows the Table is showing.+ const rs=e.target.closest('.row-sel');+ if(rs){+ e.preventDefault(); e.stopPropagation();+ document.body.classList.remove('tbl-picking');+ const tr=rs.closest('tr[data-key]'), hr=tr&&rowItem(tr.dataset.key);+ if(hr)selectFromHandle(hr,e,'table');+ return;+ } const ul=e.target.closest('.uid-link'); if(ul){ e.stopPropagation(); const tr=ul.closest('tr'); copyRowLink(rowItem(tr&&tr.dataset.key),ul); return; } const del=e.target.closest('.row-del');@@ -14000,7 +14105,8 @@ function deleteRows(victims,via){ pushUndo({type:'multi',steps}); clearSelection(); keepTailLast();applyFilter();refreshChartGeometry();renderTable();- undoToast.innerHTML='Deleted '+steps.length+' rows · <kbd>Ctrl+Z</kbd>';+ // One row is named, the way the single delete names it ("Deleted 1 rows" read badly).+ undoToast.innerHTML='Deleted '+(steps.length===1?esc(steps[0].r.name||steps[0].r.label||'1 row'):steps.length+' rows')+' · <kbd>Ctrl+Z</kbd>'; showToast(undoToast,4500,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;}); scheduleSave(via||'table-delete'); return steps.length;@@ -14033,10 +14139,29 @@ function armDelete(victims){ if(v._msLine)v._msLine.classList.add('del-armed'); }); delArm={uids:victims.map(v=>v.uid),timer:setTimeout(disarmDelete,4000)};+ markArmedRows(); undoToast.__armed=true;- undoToast.innerHTML='Press <kbd>Delete</kbd> again to delete '+n+(n===1?' row':' rows');+ // One row is named, so the question says WHICH row (the Table's cursor and its+ // selection can be two different rows); a set is counted.+ undoToast.innerHTML='Press <kbd>Delete</kbd> again to delete '+(n===1?'“'+esc(victims[0].name||victims[0].label||'this row')+'”':n+' rows'); showToast(undoToast,4000,()=>{undoToast.__armed=false;undoToast.innerHTML=UNDO_TOAST_DEFAULT;disarmDelete();}); }+// The Table's rows wear the armed mark too. Called again after the Table repaints (from+// markSelectedRows), so a live change landing inside the four seconds keeps it on.+function markArmedRows(){+ if(!tableBody||!delArm)return;+ tableBody.querySelectorAll('tr[data-key]').forEach(t=>{+ const r=rowItem(t.dataset.key);+ t.classList.toggle('del-armed',!!(r&&r.uid&&delArm.uids.includes(r.uid)));+ });+}+// The second press acts on what the first press NAMED. Anything that could have changed+// the target in between (a click, any other key, the timeout) has already disarmed, so+// the two sets can only differ if something new arrived, and re-arming on the fresh set+// is the honest answer to that.+function armedFor(victims){+ return !!delArm&&delArm.uids.length===victims.length&&victims.every(v=>delArm.uids.includes(v.uid));+} // The chart's Delete is only live when the chart is what you are looking at and // nothing else has the keyboard. appIsIdle() is the same gate the table's Delete and // the arrow keys use, so a Delete inside a name field, a date input or an open panel@@ -14058,15 +14183,39 @@ function chartDeleteKey(e){ const victims=selectedRows(); if(!victims.length){disarmDelete();return;} e.preventDefault();- // The second press acts on what the first press NAMED. Anything that could have- // changed the selection in between (a click, any other key, the timeout) has- // already disarmed, so these two sets can only differ if something new arrived,- // and re-arming on the fresh set is the honest answer to that.- const same=delArm&&delArm.uids.length===victims.length&&victims.every(v=>delArm.uids.includes(v.uid));- if(!same){armDelete(victims);return;}+ if(!armedFor(victims)){armDelete(victims);return;} disarmDelete(); deleteRows(victims,'chart-delete'); }+// The Table's Delete key, armed the same way, so Delete twice means one thing in both+// lenses. Until the row handle it deleted at the first press, and a second press out of+// the chart's habit took the next row too. It acts on the SELECTION when there is one+// (what the handle, Ctrl+click and Shift+click make, and what is highlighted), and with+// nothing selected on the focused row, the arrows' cursor. One row goes by deleteRow (the+// row button's path), a set by deleteRows; either way the focus then lands on the row that+// slid up into the first one's place, so a cleanup run is Delete twice, Delete twice.+function tableDeleteKey(e){+ const rows=visibleRowEls();+ const fi=rows.findIndex(t=>t.dataset.key===focusedKey);+ let victims=selectedRows();+ if(!victims.length&&fi>=0){ const fr=rowItem(focusedKey); if(fr)victims=[fr]; }+ if(!victims.length){disarmDelete();return;}+ e.preventDefault();+ if(!armedFor(victims)){armDelete(victims);return;}+ disarmDelete();+ const gone=new Set(victims.map(rowKey));+ const at=rows.findIndex(t=>gone.has(t.dataset.key));+ if(victims.length===1){+ const r=victims[0];+ if(r.uid)selected.delete(r.uid);+ deleteRow(r);+ refreshSelVisuals(); updateSelCount();+ } else deleteRows(victims,'table-delete');+ // The list is shorter now, so the same index is the row that moved up, and the clamp+ // inside focusRowAt lands on the last row when the last one went.+ const left=visibleRowEls().length;+ if(left)focusRowAt(Math.min(at>=0?at:Math.max(fi,0),left-1));+} // One row, from a panel's Delete button. Straight to deleteRow, which is the table // button's path: same undo entry, same toast, same retired uid, same dropped // dependencies. The panel goes first so nothing is left open over a row that has left
install.shpackage.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.78.1",+ "version": "0.78.2", "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.78.1",+ "version": "0.78.2", "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": {
test/ui_test.mjs+239@@ -909,6 +909,7 @@ async function main() { if (wantAny('row links')) await ownServers(rowLinksFlow, browser); if (wantAny('scroll sync')) await ownServers(scrollSyncFlow, browser); if (wantAny('milestone deps')) await ownServers(milestoneDepsFlow, browser);+ if (wantAny('row select')) await ownServers(rowSelectFlow, browser); if (wantAny('viewport')) { // ── Viewport matrix ──@@ -1294,6 +1295,10 @@ const MANIFEST = [ { sel: '#btnCopyIds', label: 'table: Copy IDs', required: false, untested: 'writes the system clipboard, which headless Chrome does not expose to the suite' }, // The table's hover-only delete. { sel: '.row-del', label: 'table row delete (hover only)', terminal: true, by: 'live: delete' },+ // The row handle: selects the row and nothing else, a view state, so the walk clicks it.+ // Shown on hover, and on a selected row; the `row select` flow proves what it does.+ { sel: '.row-sel', within: 'labels', label: 'chart row: the handle, selects the row (hover only)', safe: true },+ { sel: '.row-sel', within: 'tableBody', label: 'table row: the handle, selects the row (hover only)', safe: true }, ]; function ctrlMatches(c, d) { let ok;@@ -3897,6 +3902,21 @@ async function milestoneDepsFlow(browser) { check('and that arrow runs from diamond to diamond', await (async () => { const x = await arrowAt('evt-sign-off', 'tooling-done'), d1 = await diamondOf(M.uid), d2 = await diamondOf(M2.uid); return !!x && near(x.startX, d1.r) && near(x.tipX, d2.l); })()); await clickMilestone(page, M2.uid); await clickBar(page, 'test'); check('a link that would close a loop through milestones is refused, and says so', J(await depsOf('test')) === '["build"]' && /loop/.test(await flashTxt()), await flashTxt());+ // A task that already waits on one gains a second (0.78.2). Dependency mode adds to the row's+ // own list in place, and the tab's copy of what the server holds shared that list, so the+ // page found nothing to send: it said "saved", and a reload lost the link (0.77.0 to 0.78.1).+ const pageDeps = id => page.evaluate(i => ((window.__collab.rows().find(r => r.id === i) || {}).deps || []), id);+ const reloadReady = async () => { await page.reload({ waitUntil: 'load' }).then(() => ready(page)); await settle(page); };+ await clickBar(page, 'ramp'); await clickBar(page, 'test');+ check('a task that already waits on one gains a second in dependency mode, and the server has both', J(await depsOf('test')) === '["build","ramp"]', J(await depsOf('test')));+ await reloadReady();+ check('and after a reload the page and the server both still have both', J(await pageDeps('test')) === '["build","ramp"]' && J(await depsOf('test')) === '["build","ramp"]', J([await pageDeps('test'), await depsOf('test')]));+ await depMode(true);+ await clickBar(page, 'ramp'); await clickBar(page, 'test');+ const afterOut = J(await depsOf('test'));+ await reloadReady();+ check('the same pair again takes that one out, on the server and after a reload', afterOut === '["build"]' && J(await pageDeps('test')) === '["build"]', J([afterOut, await pageDeps('test')]));+ await depMode(true); await clickBar(page, 'test'); await clickMilestone(page, M.uid); check('clicking the same pair again takes a milestone\'s link out', !(await depsOf('evt-sign-off')).includes('test'), J(await depsOf('evt-sign-off'))); await undo(page);@@ -4002,6 +4022,225 @@ async function milestoneDepsFlow(browser) { await page.close(); } +// ── The row handle ──+// A strip on the left edge of every task and milestone row, in the chart's name column and+// the Table's ID cell, that selects the row and opens nothing. Drew: nowhere on a row just+// selected it, so deleting one meant opening the editor and pressing its Delete. Walked+// with the mouse and the keyboard as a person would, and each change checked on the server.+async function rowSelectFlow(browser) {+ section = 'row select';+ const T = (id, name, ph, extra) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew', status: 'in-progress', ...extra });+ const LONG = 'Alpha two, a name long enough to run on past both of its pills and keep going';+ const X = await startServer({ project: { title: 'Select Test', appName: 'select-test', view: { mode: 'gantt', grain: 'week', labelWidth: 300, milestones: 'diamonds' } },+ phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-11' }, { name: 'Later', start: '2026-10', end: '2026-11' }],+ items: [T('a1', 'Alpha one', 0), T('a2', LONG, 0, { tags: ['long-lead'] }), T('a3', 'Alpha three', 0), { type: 'milestone', name: 'Alpha gate', date: '2026-10-20', phase: 0 },+ T('b1', 'Bravo one', 1), T('b2', 'Bravo two', 1), T('c1', 'Charlie one', 2), T('c2', 'Charlie two', 2)] }, 'select');+ const page = await openPage(browser, X.url);+ const J = v => JSON.stringify(v);+ const order = async () => (await savedPlan(X.url)).items.filter(r => r.type !== 'group').map(r => r.id || r.name);+ const has = async id => (await order()).includes(id);+ // Where a row's handle is, the row brought into view first, by its name in either lens.+ const locate = (name, lens) => page.evaluate((n, lens) => {+ const rows = lens === 'table' ? [...document.querySelectorAll('#tableBody tr[data-key]')] : [...document.querySelectorAll('#labels .label-row:not(.group)')];+ const e = rows.find(x => ((lens === 'table' ? x.querySelector('td.c-name') : x.querySelector('.name-text')) || {}).textContent?.replace('◆', '').trim() === n);+ if (!e) return null;+ const h = e.querySelector('.row-sel'), r = e.getBoundingClientRect();+ const nm = lens === 'table' ? e.querySelector('.uid-link') : e.querySelector('.name-text');+ if (!h) return { rowX: r.left + r.width / 2, rowY: r.top + r.height / 2 };+ // The name's TEXT: its box reaches 3px to the left of it for the hover outline.+ let nb = null; if (nm) { if (lens === 'table') nb = nm.getBoundingClientRect(); else { const rg = document.createRange(); rg.selectNodeContents(nm); nb = rg.getBoundingClientRect(); } }+ const b = h.getBoundingClientRect();+ const top = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2);+ return { rowX: r.left + Math.min(r.width / 2, 200), rowY: r.top + r.height / 2, x: b.left + b.width / 2, y: b.top + b.height / 2, w: b.width, h: b.height, rowH: r.height,+ left: b.left - r.left, right: b.right, nextLeft: nb ? nb.left : null, onTop: top === h || h.contains(top), op: +getComputedStyle(h).opacity, tip: h.title };+ }, name, lens);+ const aim = async (name, lens = 'gantt') => {+ await page.evaluate((n, lens) => { const rows = lens === 'table' ? [...document.querySelectorAll('#tableBody tr[data-key]')] : [...document.querySelectorAll('#labels .label-row:not(.group)')];+ const e = rows.find(x => ((lens === 'table' ? x.querySelector('td.c-name') : x.querySelector('.name-text')) || {}).textContent?.replace('◆', '').trim() === n); if (e) e.scrollIntoView({ block: 'center' }); }, name, lens);+ await settle(page, 80);+ return locate(name, lens);+ };+ // Hover the row (the handle shows on hover, as for a person), then click the handle.+ const pick = async (name, mods = [], lens = 'gantt') => {+ const a = await aim(name, lens);+ if (!a || !a.w) throw new Error(`rowSelectFlow: no handle on "${name}" (${lens})`);+ await page.mouse.move(a.rowX, a.rowY); await settle(page, 60);+ for (const m of mods) await page.keyboard.down(m);+ await page.mouse.click(a.x, a.y);+ for (const m of mods) await page.keyboard.up(m);+ await settle(page, 150);+ };+ const picked = (lens = 'gantt') => page.evaluate(lens => lens === 'table'+ ? [...document.querySelectorAll('#tableBody tr.multi-sel td.c-name')].map(t => t.textContent.replace('◆', '').trim())+ : [...document.querySelectorAll('#labels .label-row.multi-sel .name-text')].map(t => t.textContent.trim()), lens);+ // Nothing opened: no editor, no rename, no cell, no list, no owner field, and the keyboard is the page's.+ const opened = () => page.evaluate(() => ({ editor: document.getElementById('editor').classList.contains('show'), rename: !!document.querySelector('.label-edit'), cell: !!document.querySelector('.cell-edit'),+ list: !!document.querySelector('.cell-list'), owner: !!document.querySelector('.pill-owner-inp'), focus: document.activeElement === document.body ? 'page' : (document.activeElement.className || document.activeElement.tagName) }));+ const nothingOpen = o => !o.editor && !o.rename && !o.cell && !o.list && !o.owner && o.focus === 'page';+ const toastTxt = () => page.$eval('#undoToast', e => e.classList.contains('show') ? e.textContent : '');+ const chip = () => page.$eval('#selCount', e => e.classList.contains('show') ? e.textContent : '');+ const esc = async (n = 1) => { for (let i = 0; i < n; i++) { await page.keyboard.press('Escape'); await settle(page, 80); } };+ const del = async () => { await page.keyboard.press('Delete'); await settle(page, 200); };++ // ── The chart ──+ await page.mouse.move(900, 860); await settle(page, 100);+ let a = await aim('Alpha one');+ check('a row\'s handle is out of sight while the row is not hovered', !!a && a.w > 0 && a.op === 0, J(a));+ await page.mouse.move(a.rowX, a.rowY); await settle(page, 200);+ a = await locate('Alpha one');+ check('hovering the row shows its handle on the left edge, full height, on top, with a tooltip', a.op === 1 && a.left <= 1 && a.w >= 10 && Math.abs(a.h - a.rowH) <= 1 && a.onTop && /Select this row/.test(a.tip), J(a));+ check('the handle sits in the row\'s own padding, clear of the name', a.nextLeft !== null && a.right <= a.nextLeft + 0.5, J(a));+ const aLong = await aim(LONG), aGate = await aim('Alpha gate');+ check('a row whose name fills the column has one too, and so does a milestone', !!aLong && aLong.w >= 10 && !!aGate && aGate.w >= 10 && aGate.right <= aGate.nextLeft + 0.5, J({ aLong, aGate }));+ await pick('Alpha one');+ let o = await opened();+ check('clicking the handle selects that row and opens nothing (no rename, no picker, no editor)', J(await picked()) === J(['Alpha one']) && nothingOpen(o), J({ sel: await picked(), o }));+ check('the selection chip counts it', /1 selected/.test(await chip()), await chip());+ await page.mouse.move(900, 860); await settle(page, 150);+ check('a selected row keeps its handle in view, in the accent', (await locate('Alpha one')).op === 1);+ await pick(LONG);+ check('a click on another row\'s handle selects it and clears the others', J(await picked()) === J([LONG]));+ await pick(LONG);+ check('clicking a selected row\'s handle keeps it selected (never a toggle)', J(await picked()) === J([LONG]));+ await pick('Alpha three', ['Control']);+ o = await opened();+ check('Ctrl+click adds a row, and opens no batch panel', J(await picked()) === J([LONG, 'Alpha three']) && nothingOpen(o), J({ sel: await picked(), o }));+ await pick('Alpha three', ['Control']);+ check('Ctrl+click again takes it out', J(await picked()) === J([LONG]));+ await pick('Alpha one');+ await pick('Bravo one', ['Shift']);+ check('Shift+click selects every row between, milestones too', J(await picked()) === J(['Alpha one', LONG, 'Alpha three', 'Alpha gate', 'Bravo one']), J(await picked()));+ await esc();+ check('one Escape clears the selection', (await picked()).length === 0 && !(await chip()));+ // A range does not reach into a folded section.+ await page.evaluate(() => [...document.querySelectorAll('#labels .label-row.group')].find(g => g.textContent.includes('Ship')).querySelector('.lane-toggle').click()); await settle(page, 200);+ await pick('Alpha one'); await pick('Charlie one', ['Shift']);+ check('a Shift range skips the rows of a folded section', J(await picked()) === J(['Alpha one', LONG, 'Alpha three', 'Alpha gate', 'Charlie one']), J(await picked()));+ await page.evaluate(() => [...document.querySelectorAll('#labels .label-row.group')].find(g => g.textContent.includes('Ship')).querySelector('.lane-toggle').click()); await settle(page, 200);+ await esc();++ // Delete twice, with the app's own question in between.+ await pick('Alpha three');+ await del();+ const armed = await page.evaluate(() => [...document.querySelectorAll('#labels .label-row.del-armed .name-text')].map(e => e.textContent));+ check('Delete once asks first: the row is marked and the toast names it', J(armed) === J(['Alpha three']) && /Press Delete again to delete .Alpha three./.test(await toastTxt()) && await has('a3'), J({ armed, t: await toastTxt() }));+ await del();+ check('Delete again deletes it, and the toast offers Ctrl+Z', !(await has('a3')) && /Deleted Alpha three/.test(await toastTxt()), J({ order: await order(), t: await toastTxt() }));+ await undo(page);+ check('Ctrl+Z brings it back, in its place', J((await order()).slice(0, 4)) === J(['a1', 'a2', 'a3', 'alpha-gate']), J(await order()));+ await pick('Bravo one'); await pick('Bravo two', ['Control']);+ await del(); await del();+ const after2 = await order();+ check('Delete twice takes a whole selection, in one go', !after2.includes('b1') && !after2.includes('b2') && /Deleted 2 rows/.test(await toastTxt()), J({ after2, t: await toastTxt() }));+ await undo(page);+ check('and one Ctrl+Z brings the whole selection back', (await has('b1')) && (await has('b2')));+ await pick('Charlie one');+ await del(); await esc();+ check('Escape withdraws the question and keeps the selection', J(await picked()) === J(['Charlie one']) && !(await page.$('#labels .label-row.del-armed')));+ await del();+ check('so the next Delete asks again rather than deleting', (await has('c1')) && !!(await page.$('#labels .label-row.del-armed')));+ await esc(2);++ // Everything else on the row still does what it did.+ a = await aim('Alpha one');+ const nameAt = await page.evaluate(() => { const e = [...document.querySelectorAll('#labels .name-text')].find(x => x.textContent === 'Alpha one'); const r = e.getBoundingClientRect(); return { x: r.left + 12, y: r.top + r.height / 2 }; });+ await page.mouse.click(nameAt.x, nameAt.y); await settle(page, 150);+ check('a click on the name still renames it in place', !!(await page.$('#labels .label-edit')));+ await esc();+ await page.evaluate(() => [...document.querySelectorAll('#labels .label-row')].find(x => x.querySelector('.name-text')?.textContent === 'Alpha one').querySelector('.status-pill').dataset.t = 'st');+ await page.click('.status-pill[data-t="st"]'); await settle(page, 150);+ check('the status pill still opens its list', !!(await page.$('.cell-list')));+ await esc();+ await page.evaluate(() => [...document.querySelectorAll('#labels .label-row')].find(x => x.querySelector('.name-text')?.textContent === 'Alpha one').querySelector('.owner-pill').dataset.t = 'ow');+ await page.click('.owner-pill[data-t="ow"]'); await settle(page, 150);+ check('the owner pill still opens the owner field', !!(await page.$('.pill-owner-inp')));+ await esc(2);+ await page.evaluate(() => { window.__lastRowLink = ''; [...document.querySelectorAll('#labels .label-row')].find(x => x.querySelector('.name-text')?.textContent === 'Alpha one').querySelector('.row-link').dataset.t = 'lk'; });+ await page.hover('.row-link[data-t="lk"]'); await page.click('.row-link[data-t="lk"]'); await settle(page, 200);+ check('the link button still copies the row\'s link', /#row=/.test(await page.evaluate(() => window.__lastRowLink || '')));+ await clickBar(page, 'a1');+ check('a click on the bar still opens the item editor', await editorShown(page));+ await esc(2);+ // The handle drags the row, up and down only.+ const datesOf = async id => { const r = (await savedPlan(X.url)).items.find(x => x.id === id); return r.start + '..' + r.end; };+ const d0 = await datesOf('a1');+ a = await aim('Alpha one');+ await page.mouse.move(a.x, a.y); await page.mouse.down();+ for (let i = 1; i <= 6; i++) { await page.mouse.move(a.x + 15 * i, a.y); await sleep(20); }+ await page.mouse.up(); await settle(page, 300);+ check('a sideways drag from the handle moves no dates', (await datesOf('a1')) === d0, await datesOf('a1'));+ a = await aim('Alpha one'); const a3 = await locate('Alpha three');+ await page.mouse.move(a.x, a.y); await page.mouse.down();+ for (let i = 1; i <= 8; i++) { await page.mouse.move(a.x, a.y + (a3.y + a3.h * 0.3 - a.y) * i / 8); await sleep(20); }+ await page.mouse.up(); await settle(page, 300);+ check('dragging the handle down moves the row', J((await order()).slice(0, 3)) === J(['a2', 'a3', 'a1']), J(await order()));+ await undo(page);+ check('(and Ctrl+Z puts it back)', J((await order()).slice(0, 3)) === J(['a1', 'a2', 'a3']), J(await order()));+ await esc(2);+ // The keyboard is the page's straight after a click on the handle.+ await pick('Charlie one');+ await page.keyboard.down('Alt'); await page.keyboard.press('ArrowDown'); await page.keyboard.up('Alt'); await settle(page, 300);+ check('right after the handle, Alt+Down moves the selected row', J((await order()).slice(-2)) === J(['c2', 'c1']), J(await order()));+ await undo(page);+ await esc(2);++ // ── The Table ──+ // In plan order (it opens sorted by deadline), where its rows can be dragged.+ await view(page, 'table');+ await page.evaluate(() => { const b = document.getElementById('toPlanOrder'); if (b && b.offsetParent) b.click(); }); await settle(page, 200);+ const tkeys = await page.$$eval('#tableBody tr[data-key]', t => t.map(x => x.dataset.key));+ check('(the Table is in plan order)', J(tkeys.filter(k => k.startsWith('t:'))) === J(['t:a1', 't:a2', 't:a3', 't:b1', 't:b2', 't:c1', 't:c2']) && tkeys[3].startsWith('m:'), J(tkeys));+ await page.mouse.move(900, 860); await settle(page, 100);+ a = await aim('Bravo one', 'table');+ check('the Table\'s handle is out of sight until its row is hovered', !!a && a.w > 0 && a.op === 0, J(a));+ await page.mouse.move(a.rowX, a.rowY); await settle(page, 200);+ a = await locate('Bravo one', 'table');+ check('hovering a Table row shows the handle at the start of the ID cell, clear of the ID link', a.op === 1 && a.onTop && a.left <= 1 && a.nextLeft !== null && a.right <= a.nextLeft + 0.5, J(a));+ check('and the ID link is still the thing under its own middle', await page.evaluate(() => { const l = [...document.querySelectorAll('#tableBody .uid-link')][4]; const r = l.getBoundingClientRect(); const t = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); return t === l || l.contains(t); }));+ await pick('Bravo one', [], 'table');+ o = await opened();+ check('clicking it selects the row, opens no cell, and makes it the focused row', J(await picked('table')) === J(['Bravo one']) && nothingOpen(o)+ && await page.evaluate(() => document.querySelector('#tableBody tr.row-focus td.c-name')?.textContent.trim()) === 'Bravo one', J({ sel: await picked('table'), o }));+ await pick('Charlie one', ['Control'], 'table');+ check('Ctrl+click adds a row in the Table too', J(await picked('table')) === J(['Bravo one', 'Charlie one']));+ await pick('Alpha three', ['Shift'], 'table');+ check('and Shift+click takes the range of rows the Table shows', J(await picked('table')) === J(['Alpha three', 'Alpha gate', 'Bravo one', 'Bravo two', 'Charlie one']), J(await picked('table')));+ await esc();+ check('one Escape clears the selection and the focused row', (await picked('table')).length === 0 && !(await page.$('#tableBody tr.row-focus')));+ await pick('Bravo one', [], 'table');+ await page.keyboard.press('ArrowDown'); await settle(page, 100);+ check('right after the handle the arrows walk the rows', await page.evaluate(() => document.querySelector('#tableBody tr.row-focus td.c-name')?.textContent.trim()) === 'Bravo two');+ await esc(2);+ await pick('Bravo two', [], 'table');+ await page.keyboard.press('Tab'); await settle(page, 150);+ check('and Tab opens the selected row\'s first cell', await page.evaluate(() => !!document.querySelector('#tableBody tr.row-focus .cell-edit') && document.querySelector('#tableBody tr.row-focus')?.dataset.key === 't:b2'));+ await esc(3);+ await pick('Charlie two', [], 'table');+ await del();+ check('Delete once in the Table asks first, the same as the chart', (await has('c2')) && !!(await page.$('#tableBody tr.del-armed')) && /Press Delete again to delete .Charlie two./.test(await toastTxt()), await toastTxt());+ await del();+ check('Delete again deletes it', !(await has('c2')) && /Deleted Charlie two/.test(await toastTxt()), J(await order()));+ await del();+ check('a third Delete asks about the row now focused, and deletes nothing', (await has('c1')) && !!(await page.$('#tableBody tr.del-armed')));+ await esc();+ await undo(page);+ check('Ctrl+Z brings the deleted row back', await has('c2'));+ await esc(2);+ const cell = await page.evaluate(() => { const td = [...document.querySelectorAll('#tableBody td.c-name')].find(t => t.textContent.trim() === 'Bravo one'); td.scrollIntoView({ block: 'center' }); const r = td.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });+ await page.mouse.click(cell.x, cell.y); await settle(page, 150);+ check('a plain click on a cell still opens its editor', !!(await page.$('#tableBody .cell-edit')));+ await esc(3);+ // Held, the handle lifts the row like the rest of it.+ a = await aim('Bravo one', 'table'); const b2 = await locate('Bravo two', 'table');+ await page.mouse.move(a.x, a.y); await page.mouse.down(); await sleep(450);+ for (let i = 1; i <= 8; i++) { await page.mouse.move(a.x, a.y + (b2.y + b2.h * 0.3 - a.y) * i / 8); await sleep(25); }+ await page.mouse.up(); await settle(page, 300);+ const ob = await order();+ check('a held press on the Table\'s handle drags the row', ob.indexOf('b2') < ob.indexOf('b1'), J(ob));+ await undo(page);+ await page.close();+}+ function cleanupSync() { if (cleanedUp) return; cleanedUp = true; for (const s of servers) { try { s.proc.kill('SIGKILL'); } catch {} }
uninstall.sh