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
0.39.0: cap band gets its own space, dependency mode signals and undoes, one Tab crosses a date
6 files changed
+458−22
README.md+2−2@@ -231,10 +231,10 @@ long editing run. | 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 | | 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 |-| Shift+D | Dependency mode: click the task that comes first, then the one that waits on it. Stays on so you can chain them. Esc clears the pick, Esc again leaves |+| Shift+D | Dependency mode: click the task that comes first, then the one that waits on it. The armed first task wears an amber ring. 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 | | 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. Under a column sort the row stays inside the run of rows the sort considers tied with it |-| 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 |+| 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 | Cancel the open field, then clear the row focus, then clear the selection | | Ctrl+click | Toggle item in selection | | Shift+click | Range select |
SKILL.md+2−2@@ -216,7 +216,7 @@ rather than a category. - **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 - **Saving is whole-state and last-write-wins.** Two people editing at once WILL overwrite each other. Each save carries the revision it was based on and a stale save is refused with 409, at which point that tab stops autosaving and offers a reload rather than clobbering the other person-- **Dependency mode** (the Deps button with the chart controls, or **Shift+D**): click the task that comes FIRST, then the task that waits on it, and the dependency is drawn. The pair resets and the mode stays on, so a run of dependencies is one sitting. Refuses duplicates and refuses anything that would make a cycle, naming the shortest loop path. Milestones are not endpoints: a dep is stored as a task id and a milestone in the data file has none. Esc clears a half-made pick, Esc again leaves the mode. While the mode is on, bar clicks mean dependencies: no editor, no drag, no reorder. Ctrl+Z removes the last one without leaving the mode+- **Dependency mode** (the Deps button with the chart controls, or **Shift+D**): click the task that comes FIRST, then the task that waits on it, and the dependency is drawn. The armed first task wears an amber ring 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. Milestones are not endpoints: a dep is stored as a task id and a milestone in the data file has none. **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. Ctrl+Z undoes the last add or removal without leaving the mode - **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 - **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@@ -226,7 +226,7 @@ rather than a category. - **Enter** on the selected row opens its NAME editor, ready to type with the text selected. Inside a cell editor Enter commits and steps down one row, the spreadsheet convention, so a column of values is Enter, type, Enter, type; it does not open the next editor, and a second Enter does - **Shift+N** opens the New panel, gated on the app being idle: no editor, panel, menu or drag in progress, and nothing focused that a person could be typing into - **Alt+Up / Alt+Down** moves one row one slot, or the WHOLE chart selection as a group when more than one row is selected: each selected row steps one place so the gaps between them survive, and if any of them has nowhere to go the press does nothing rather than squashing the selection against the end. One undo per press either way. The table moves its focused row (the row whose cell editor was touched last, marked with an accent bar on its left edge); the chart moves its selected task. In plan order the movement is free, and stepping over a group header re-homes the row into that lane, phase, bar colour and dashed rule together, exactly as a drag does. Under a column sort the movement is bounded to the contiguous run of rows the sort considers TIED with this one, so an owner-sorted Adonis task moves within the Adonis block and nowhere else: the visible sort stays truthful and what changes is the tie-break, which is plan position, which persists. A row alone in its sort key does not move and says so. Undo is per press; the changelog coalesces a burst into one line-- **Tab / Shift+Tab** walks the selected row's fields at one keypress per column, including the select and date columns. A select cell shows its choices in the app's own list rather than the browser's popup, because that popup swallows the first Tab to close itself; the select keeps all its native keyboard behaviour. Date cells keep their native arrow keys for segments and values, and Tab leaves the field instead of walking its segments. With no editor open it opens the first editable cell (Shift+Tab the last); inside an editor it commits the field and opens the next editable cell, with the same ready-to-type conventions as a click (a select opens its list, short text is selected, notes get the caret at the end). It skips read-only cells and the delete button, follows the visible order under a sort, and wraps across rows and around both ends of the table+- **Tab / Shift+Tab** walks the selected row's fields at one keypress per column, including the select and date columns, and does the same thing field by field in every edit panel (task, milestone, item, New and batch): one Tab leaves a date field for the next field rather than stepping through its segments, in both directions, while the arrows and typing still edit the segments normally. A select cell shows its choices in the app's own list rather than the browser's popup, because that popup swallows the first Tab to close itself; the select keeps all its native keyboard behaviour. Date cells keep their native arrow keys for segments and values, and Tab leaves the field instead of walking its segments. With no editor open it opens the first editable cell (Shift+Tab the last); inside an editor it commits the field and opens the next editable cell, with the same ready-to-type conventions as a click (a select opens its list, short text is selected, notes get the caret at the end). It skips read-only cells and the delete button, follows the visible order under a sort, and wraps across rows and around both ends of the table - **Auto-save** to `<datafile>-state.json` sidecar (800ms debounce, flushed immediately if the page is closed or hidden, so the changelog and the sidecar cannot disagree) - **Auto-load** saved state on page refresh - **Dynamic date range** computed from the data every render
docs/BUILD-NOTES.md+140@@ -3,6 +3,141 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-18 (0.39.0, the cap band gets its own space, dependency mode says what it is doing and can undo it, and one Tab crosses a date)++Four changes, all in the client. `server.js` is untouched, so the running server was never+restarted.++**A. The milestone cap band stops sitting on the first rows.**++0.37.0 recorded this as a known quirk and 0.38.0 fixed where the band anchors without+touching that it overlapped: the caps float at `z-index: 7` with `pointer-events: auto` in a+band that begins 4px under the anchor header, which is exactly where the first bar rows are.+Measured on a bench built to make it happen: the first row's bar spans y 163.4 to 183.4 and a+cap spans 160.4 to 177.6, so `elementFromPoint` at the bar's own centre answered `ms-cap`, and+13 of the bar's 20 pixel rows were the cap's across the cap's width. A milestone dated at a+bar's start or end put a cap over its resize handle the same way.++Nothing larger than a cap was ever taking the clicks. `.ms-line` has been `pointer-events:+none` throughout and a cap's hit region is exactly its own painted box, which the grid probe+confirms to the pixel. That is what makes "who wins the shared pixel" the wrong question to+answer: the cap really IS painted there, so whichever one wins, the other is being clicked+through something opaque the user can see on top of it. And it cannot be settled with a+colour either. The three lane fills sit mid-luminance and no flat colour clears 3:1 against+both them and a near-black page, which is the same arithmetic that shapes change B below.++So the band is given room instead. The anchor header carries a `margin-bottom` the height of+the band, in BOTH columns or they drift apart by exactly that much, and every row below now+starts under the band rather than behind it. `capBandBase` reads `offsetTop + offsetHeight`+and a margin-BOTTOM changes neither, so the caps do not move at all and one pass is enough:+only the plan below them moves down. The today tag is reserved for by the same rule in the+Diamonds style, where there are no caps but the tag is in the same band and was taking clicks+by the identical mechanism.++One ordering trap, and the v38 suite caught it immediately. A reserve left on a FORMER anchor+is ABOVE the new one, so it is part of where the new one is. Measuring the base first and+clearing the old reserve second laid the caps out against a position the clear then took away+from under them, a whole band's height of stale offset, which showed as the band landing 27px+below the header it belongs to after the top section was collapsed. The clear now happens+before the measurement. The reserve on the anchor itself is a margin-bottom and cannot move+the anchor, which is what keeps this to one pass rather than a settle loop.++A dim cap lost its clicks in the same change. A cap washed out to `opacity: 0.15` by a phase+filter is not a cap you can read, and unlike a dim BAR it floats over other rows rather than+sitting in its own, so it was a ghost taking clicks meant for whatever showed through it. Dim+bars deliberately keep theirs: nothing is underneath a bar to take them from.++**B. Dependency mode shows the task it has armed.**++`setDepPending` has been adding a `dep-pending` class since 0.22.0 and there has never been a+rule for it, so after the first click of a pair only the caption knew which task was armed.++The signal is one colour, used for the armed source and for a picked arrow and for nothing+else in this app, which the suite asserts by walking the stylesheet: three rules reference the+token and all three are dependency-mode rules. It is a RING OF TWO BANDS rather than one flat+colour, and that is arithmetic rather than taste. The lane fills are teal 0.37, purple 0.23+and blue 0.39 relative luminance against a 0.009 page, so a single colour that clears 3:1+against the lightest fill would have to be lighter than 0.93, which is white, and one that clears the+page would have to be far darker than that. No flat colour exists. So the band that touches+the FILL and the band that touches the PAGE are chosen separately, against what each actually+abuts: `--sig-edge` inside, `--sig` outside, and the pair reads as one amber ring. Measured on+the rendered page, both themes, every lane:++| | outer band vs page | ring vs teal | ring vs purple | ring vs blue | band vs band |+|---|---|---|---|---|---|+| dark `--sig #ffb02e` / `--sig-edge #05080b` | 10.36 | 8.12 | 5.32 | 8.42 | 11.00 |+| light `--sig #b45309` / `--sig-edge #ffffff` | 5.02 | 3.09 | 3.78 | 3.03 | 5.02 |++The bar goes back to `opacity: 1` while armed, the same thing `.bar.selected` does and for the+same reason: opacity applies to an element's own shadow, so a ring painted through 0.82 would+arrive at less contrast than it was picked for. There is no milestone case to style. A+milestone carries no `id`, a dependency is stored as an id, and the mode says so rather than+inventing an id namespace, so a milestone can never be the armed end of a pair.++**And the mode can now take a dependency back.** Clicking a pair that already has an edge used+to say "already needs" and do nothing, which left removal as a job for the editor only: the+mode could add an edge but never undo one, and the one gesture that obviously means "not that"+was the one gesture it refused. It removes now, down the same `removeDep` the editor's × goes+down, so there is one removal in this app however it is reached: one edit snapshot (which+already carries `deps`, hence one Ctrl+Z and redo for free) and the same `dep.remove` the+server derives from the same state change, with only `via` telling the two apart. The suite+proves the parity by running both paths and comparing the events.++**And an arrow is a thing you can pick.** Only in the mode. The arrow layer stays+`pointer-events: none` in EVERY mode, so picking one is a hit TEST rather than a hit target: a+click that landed on nothing else is measured against the routes the browser actually drew and+the nearest within 9px is picked. That is what makes "normal mode is untouched" structural+rather than conditional, because there is no state in which the layer can take a click, and it gives+the right precedence for free, since a bar always wins over an arrow crossing it. A picked+arrow gets the signal colour, a 3px stroke and a `--sig-edge` halo for the stretches where a+route crosses a bar. Delete is armed the way the chart's row Delete has been since 0.37.0: the+first press marks it in red and says so, the second inside four seconds removes it, and any+other key, any mousedown, Escape or the timeout withdraws the question through the same two+capturing listeners, which now watch both arms.++**C. One Tab crosses a date field.**++Measured before the change: four Tab presses to cross one date input, because the browser+walks its day, month and year segments first, and eight to cross a start and a deadline. The+table answered this in 0.22.2 by claiming Tab in the capture phase, before the input's own+default action can run, and the panels get the same answer so the two lenses tab alike.++Only Tab, and only OUT OF a date field. Inside one the arrows still move between segments and+still change the value and typing still fills them, because none of those keys are touched,+and the suite asserts all three with the focus staying put. Every other field keeps the+browser's own walk, which was already one stop per field. At either end of a panel's cycle the+handler stands down, because that is the way out of the panel and this has no opinion about+it. Every panel in this app renders into the same element, so one listener covers the task+editor, the milestone editor, the full item editor, the create panel and the batch panel, and+any panel added later is covered before it is written.++**Verification.** A new `v39` suite, 176 assertions, real input throughout. Every reachability+claim is an `elementFromPoint`, never an assumed rectangle: the first three rows are hit at the+bar body, at the top and bottom pixel rows of the bar and at both resize handles, in all three+milestone styles and again with the top section collapsed so the band's anchor has moved, and+a separate sweep intersects every band item's rectangle with every bar's and asserts the list+of overlaps is empty. The caps are proven still live in the same block: a cap still answers+`elementFromPoint` on its own box, still opens its milestone editor, and still drags its date.+The contrast figures above are computed on the rendered page from the resolved tokens and the+bars' own computed fills, in both themes. Both dependency-removal paths are checked against+the sidecar and the changelog rather than against the screen, and each is undone with one+Ctrl+Z. The single-Delete-then-Escape and single-Delete-then-another-key cases assert that+nothing was removed. Normal mode is asserted three ways: the layer's computed+`pointer-events`, `elementFromPoint` on a route answering the row beneath it, and a click there+picking and arming nothing.++Full standing battery green alongside it: v37 203, regress 26, abc 75, v38 249, v39 176, plus+the legacy `cols`, `uid`, `memo`, `dblist` and `deps` suites, zero JS errors throughout.+`mssel` and `msphase` remain fixture-coupled, both dying on a harness `FATAL` reaching for rows+that plan does not have, and their output is BYTE-IDENTICAL on 0.38.0 and 0.39.0 served the+same plan from the same port, so they are unchanged rather than green. Worth recording for the+next round: `fuser -k` was not reliably reaping the bench servers, so a suite could quietly run+against a process started earlier; the reset scripts now kill the exact PID carrying+`--port <n>` on its command line instead. It changed no result here, because `server.js` reads+`gantt.html` from disk per request and every run was therefore serving the current build, but+it did silently invalidate the first A/B until the served bytes were checked rather than+assumed.+ ## Build notes, 2026-08-18 (0.38.0, dependency arrows routed as a Z, the caret lands where the name was clicked, and the milestone cap band stops anchoring to the window) Three changes, all in the client. `server.js` is untouched, so the running server was never@@ -266,6 +401,11 @@ handle. This is not new and is not what this round was for; the suite works arou hit-testing every press point with `elementFromPoint` rather than assuming a rectangle is reachable, which is a better habit for these suites anyway. +> **Fixed in 0.39.0.** The band is given its own space rather than sharing the rows: the+> anchor header carries a margin the height of the band, in both columns, and no cap or today+> tag intersects any bar. The suites keep the `elementFromPoint` habit, which is what proves+> it. See the 0.39.0 entry, change A.+ ## Build notes, 2026-08-18 (0.36.0, the task panel is square and stays where you put it, the owner is a pill on the chart, and Enter finishes a rename) Three changes, all in the client. `server.js` is untouched, so the running 0.35.0
gantt.html+311−15@@ -42,6 +42,19 @@ so today reads as neutral grey and stops competing with both. */ --today: var(--text2); --ok: #3fb950; --warn: #d29922;+ /* The dependency-mode signal. One colour, for two things and nothing else in this+ app: the armed FIRST task of a pair, and a picked dependency arrow. It is never a+ status, a phase, an accent or a warning, so seeing it means one thing.++ It is a RING of two bands rather than one flat colour, and that is arithmetic+ rather than taste. The three lane fills sit mid-luminance (teal 0.37, purple 0.23,+ blue 0.39 relative) with a near-black page behind them, so NO single colour can+ clear 3:1 against both: to beat the lightest fill it would have to be lighter than+ 0.93, which is white, and to beat the page it would have to be far darker than+ that. So the band that touches the FILL and the band that touches the PAGE are+ chosen separately, each against what it actually abuts, and the pair reads as one+ amber ring. --sig-edge is the inner band, --sig the outer one. */+ --sig: #ffb02e; --sig-edge: #05080b; --sig-glow: rgba(255,176,46,0.30); --on-accent: #05221f; --grid-line: rgba(48,54,61,0.18); --weekend: rgba(255,255,255,0.022);@@ -62,6 +75,10 @@ --text: #1f2328; --text-body: #24292f; --text2: #59636e; --text3: #8c959f; --accent: #00857f; --accent-hover: #00a49d; --accent-bright: #00a49d; --today: var(--text2); --ok: #1a7f37; --warn: #9a6700;+ /* Same ring, both bands flipped for paper: the inner band is the page white, which+ clears 3:1 against all three light fills, and the outer band is the deep amber,+ which clears 5:1 against the page. */+ --sig: #b45309; --sig-edge: #ffffff; --sig-glow: rgba(180,83,9,0.28); --on-accent: #ffffff; --grid-line: rgba(31,35,40,0.10); --weekend: rgba(31,35,40,0.04);@@ -78,6 +95,7 @@ --text:#1f2328; --text-body:#24292f; --text2:#59636e; --text3:#8c959f; --accent:#00857f; --today:var(--text2); --grid-line:rgba(31,35,40,0.10); --weekend:rgba(31,35,40,0.04); --ph1:#00a49d; --ph2:#8c6bf7; --ph3:#3b95ff;+ --sig:#b45309; --sig-edge:#ffffff; --sig-glow:rgba(180,83,9,0.28); color-scheme: light; } html, body { overflow: visible !important; height: auto !important; } .labels-col, .timeline-col { overflow: visible !important; }@@ -265,6 +283,14 @@ body.col-resizing { cursor: col-resize; user-select: none; } .bar { position: absolute; top: 7px; height: 20px; border-radius: 6px; opacity: var(--bar-opacity, 0.82); cursor: pointer; transition: opacity 0.15s; } .bar:hover { opacity: 1; } .bar.selected { opacity: 1; outline: 2px solid var(--accent); outline-offset: 1px; }+/* The armed FIRST task of a dependency pair. Until 0.39.0 the class was set and there+ was no rule for it, so after the first click nothing on the chart said which task+ was armed and only the caption knew. Opacity goes back to 1 for the same reason+ .bar.selected does: opacity applies to the element's own shadow too, so a ring+ painted through 0.82 would arrive at less contrast than it was chosen for. Two+ bands, inner then outer, so the ring has 3:1 against the fill on one side and+ against the page on the other whatever lane the bar is in. */+.bar.dep-pending { opacity: 1; box-shadow: 0 0 0 2px var(--sig-edge), 0 0 0 4px var(--sig), 0 0 12px 4px var(--sig-glow); } .bar-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 700; color: var(--on-accent); pointer-events: none; overflow: hidden; white-space: nowrap; padding: 0 6px; } :root[data-theme="dark"] .bar-note { color: rgba(6,20,24,0.82); } .bar-note-edit { position: absolute; inset: 0; width: 100%; height: 100%; background: var(--bg); border: 1px solid var(--accent); border-radius: 6px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 10px; text-align: center; outline: none; z-index: 20; }@@ -299,7 +325,12 @@ body.col-resizing { cursor: col-resize; user-select: none; } opacity goes on the cap and the dashed rule, never on .ms-line itself, for the stacking reason spelled out above. A COLLAPSED phase hides its milestone outright via .row-collapsed; this is only the dim state. */-.ms-line.ms-dim .ms-cap { opacity: 0.15; }+/* pointer-events go with the opacity. A cap washed out to 0.15 is not a cap you can+ read, and it floats over OTHER rows' bars rather than sitting in its own, so leaving+ it clickable would mean a ghost taking a click meant for the bar showing through it.+ A dim BAR keeps its clicks, deliberately: it is still in its own row and nothing is+ underneath it to take them from. */+.ms-line.ms-dim .ms-cap { opacity: 0.15; pointer-events: none; } .ms-line.ms-dim::before { opacity: 0.15; } /* Each style hides the half it does not draw. "both" names neither, so it hides nothing and the dashed rule, its cap and the diamond are all on screen together.@@ -326,6 +357,18 @@ body.col-resizing { cursor: col-resize; user-select: none; } .dep-svg path { fill: none; stroke: var(--accent); stroke-width: 1.5; opacity: 0.5; transition: opacity 0.15s; } .dep-svg polygon { fill: var(--accent); opacity: 0.5; transition: opacity 0.15s; } .dep-svg.hidden path, .dep-svg.hidden polygon { opacity: 0; }+/* A PICKED dependency, in dependency mode only. The whole layer stays+ pointer-events:none in every mode (see depEdgeAt: picking an arrow is a hit test+ against the drawn route, not a hit target laid over the plan), so nothing here can+ take a click from the bars underneath. The drop-shadow is the inner band of the same+ two-band ring the armed bar wears: the stroke is --sig against the page, and the+ halo is --sig-edge for the stretches where a route crosses a bar. */+.dep-svg path.sel { stroke: var(--sig); stroke-width: 3; opacity: 1;+ filter: drop-shadow(0 0 1.5px var(--sig-edge)) drop-shadow(0 0 1.5px var(--sig-edge)); }+.dep-svg polygon.sel { fill: var(--sig); opacity: 1; }+/* Armed for removal, in the red this app already uses for a delete about to happen. */+.dep-svg path.del-armed { stroke: #f85149; stroke-width: 3; opacity: 1; filter: none; }+.dep-svg polygon.del-armed { fill: #f85149; opacity: 1; } /* ── Tooltip ── */ .tip { display: none; position: fixed; z-index: 99999; background: rgba(22,27,34,0.92); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid rgba(0,184,177,0.2); border-radius: 10px; padding: 10px 14px; max-width: 340px; box-shadow: 0 12px 40px rgba(0,0,0,0.55); pointer-events: none; font-family: 'Satoshi', sans-serif; font-size: 12px; line-height: 1.5; color: var(--text); }@@ -1882,15 +1925,16 @@ function wireGlobalKeys(){ // listeners capture, so they run before whatever else the key or the click is for and // the armed state can never survive into an action the user took instead. document.addEventListener('keydown',e=>{- if(!delArm)return;+ if(!delArm&&!depArm)return; if(e.key==='Delete')return; // the answer; handled in the main handler disarmDelete();+ disarmDepDelete(); // Escape's whole job here was to call off the delete, so it stops here. Letting it // fall through would also drop the selection, which is the thing the user is still // working with and did not ask to lose. if(e.key==='Escape'){e.preventDefault();e.stopPropagation();} },true);-document.addEventListener('mousedown',()=>{ if(delArm)disarmDelete(); },true);+document.addEventListener('mousedown',()=>{ if(delArm)disarmDelete(); if(depArm)disarmDepDelete(); },true); document.addEventListener('keydown',e=>{ // Undo and redo live in the same handler and are therefore guarded identically, // which is the point: whatever stops Ctrl+Z from firing stops Ctrl+Shift+Z too.@@ -1935,6 +1979,10 @@ document.addEventListener('keydown',e=>{ // 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. 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+ // away, so nothing else is competing for it.+ if(depDeleteReady()){ depDeleteKey(e); return; } // The chart lens answers Delete too, on the second press. Checked before the // table gate because the two are mutually exclusive by view and this one has to // see the key first or tableNavReady would return and eat it.@@ -2037,8 +2085,10 @@ document.addEventListener('keydown',e=>{ // The sections modal owns Escape while it is up; its own handler runs first and // stops this one, so reaching here means it is already closed. if(secModal){ closeSections(); return; }- // Innermost first here too: the half-made dependency goes before the mode that- // is making it.+ // Innermost first here too: a picked arrow, then the half-made dependency, then the+ // mode that is making them. An ARMED arrow is inside all of them and never reaches+ // here at all, because the capturing withdrawal listener above takes that Escape.+ if(depMode&&depSel){ setDepSel(null); return; } if(depMode&&depPending){ setDepPending(null); return; } if(depMode){ setDepMode(false); return; } // The chart's own inline name editor. It was missing from this stack entirely, so@@ -2870,13 +2920,68 @@ function capBandBase(){ } return Math.round(28*(prefs.fs||1))+4; }+// ── The band gets its own space ──+//+// The caps and the today tag float in a band that starts just under the anchor header,+// which is where the FIRST bar rows are. So a cap, which is an opaque box with+// pointer-events on it at z-index 7, lay on top of a task bar and took the clicks+// meant for it, its resize handles included. 0.37.0 recorded that as a known quirk and+// worked around it in the suites; 0.38.0 fixed where the band anchors but not that it+// overlapped.+//+// Deciding who wins a shared pixel is the wrong question, because the cap really is+// painted there: whatever the answer, one of the two is being clicked through+// something the user can see on top of it. So the band is given room instead. The+// anchor header carries a margin-bottom the height of the band, in BOTH columns or+// they drift apart by exactly that much, and every row below now begins under the band+// rather than behind it. capBandBase reads offsetTop + offsetHeight, and a+// margin-BOTTOM changes neither, so the caps do not move and there is no second pass:+// only the plan below them moves down.+//+// The reserve is cleared off the old anchor whenever the anchor changes, which is what+// collapsing the top section does.+let capBandOwner=null;+function clearCapBandReserve(g){+ if(!g)return;+ if(g._lDiv)g._lDiv.style.marginBottom='';+ if(g._bDiv)g._bDiv.style.marginBottom='';+ if(capBandOwner===g)capBandOwner=null;+}+function setCapBandReserve(anchor,px){+ if(capBandOwner&&capBandOwner!==anchor)clearCapBandReserve(capBandOwner);+ if(!anchor||px<=0){ clearCapBandReserve(anchor); return; }+ if(anchor._lDiv)anchor._lDiv.style.marginBottom=px+'px';+ if(anchor._bDiv)anchor._bDiv.style.marginBottom=px+'px';+ capBandOwner=anchor;+}+// capBandBase() already puts the band 4px below the header, so that 4 is part of what+// has to be reserved; the second 4 is clear air between the bottom of the band and the+// first bar row, so a cap and a bar are never merely touching.+const CAP_BAND_GAP=4;+function todayTagInBand(){+ return !!(todayTagEl&&todayTagEl.style.display!=='none'&&prefs.showToday!==false);+} function layoutMsCaps(){+ const anchor=capBandAnchor();+ // A reserve left on a FORMER anchor is above the new one, so it is part of where the+ // new one is. It therefore has to go before the base is measured, not after: measure+ // first and clear second and the caps are laid out against a position the clear then+ // takes away from under them, which is a whole band's height of stale offset.+ // Collapsing the top section is the gesture that moves the anchor, so that is where+ // it showed. The reserve on the anchor ITSELF is a margin-BOTTOM and cannot move the+ // anchor, which is what makes one pass enough.+ if(capBandOwner&&capBandOwner!==anchor)clearCapBandReserve(capBandOwner); const base=capBandBase(); // The today tag shares the band, so it rides the anchor too, in both milestone // styles. Keeping it level with the caps means a collapsed top section can never // strand it behind a header either. if(todayTagEl)todayTagEl.style.top=base+'px';- if(!msShowsLine())return;+ if(!msShowsLine()){+ // No caps in this style, but the today tag is still in the band and takes clicks+ // by exactly the same mechanism, so it is reserved for by exactly the same rule.+ setCapBandReserve(anchor,todayTagInBand()?CAP_BAND_GAP+(todayTagEl.offsetHeight||14)+CAP_BAND_GAP:0);+ return;+ } const caps=[]; items.filter(r=>r.type==='milestone'&&r._msLine&&!r._collapsed).forEach(r=>{ const cap=r._msLine.querySelector('.ms-cap');@@ -2888,7 +2993,7 @@ function layoutMsCaps(){ // LEFT of something already placed. Row 0 starts pre-occupied by the today tag, // which shares this band and would otherwise be overlapped. const rows=[[]];- if(todayTagEl&&todayTagEl.style.display!=='none'&&prefs.showToday!==false){+ if(todayTagInBand()){ const tw=todayTagEl.offsetWidth||34, tx=todayX(); rows[0].push([tx-tw/2-GAP, tx+tw/2+GAP]); }@@ -2910,6 +3015,16 @@ function layoutMsCaps(){ rows[n].push([l,r]); c.cap.style.top=(base+n*rowH)+'px'; });+ // The band is as deep as the last row it used plus the tallest thing standing on+ // that row. rowH already carries the 3px pitch padding, so the last row is measured+ // rather than assumed: a two-line cap on the bottom row must not hang into the plan.+ const lastTop=(rows.length-1)*rowH;+ let tall=todayTagInBand()&&rows.length===1?(todayTagEl.offsetHeight||14):0;+ caps.forEach(c=>{+ const n=Math.round((parseFloat(c.cap.style.top)-base)/rowH);+ if(n===rows.length-1)tall=Math.max(tall,c.cap.offsetHeight||14);+ });+ setCapBandReserve(anchor,CAP_BAND_GAP+lastTop+(tall||rowH)+CAP_BAND_GAP); } function keepTailLast(){@@ -3036,10 +3151,19 @@ function drawDeps(){ // put every arrow in the wrong place the moment the text scale moved it. const sr=sb0(src),tr=sb0(tgt); const sb=src.bar,tb=tgt.bar,x1=sb.offsetLeft+sb.offsetWidth,y1=sr.offsetTop+sr.offsetHeight/2,x2=tb.offsetLeft,y2=tr.offsetTop+tr.offsetHeight/2;- const p=document.createElementNS('http://www.w3.org/2000/svg','path');p.setAttribute('d',depPath(x1,y1,x2,y2));depSvg.appendChild(p);- const a=document.createElementNS('http://www.w3.org/2000/svg','polygon');a.setAttribute('points',`${x2},${y2} ${x2-6},${y2-3} ${x2-6},${y2+3}`);depSvg.appendChild(a);+ // Which dependency this route IS, carried on the drawn elements. Attributes+ // rather than extra nodes: the layer is redrawn wholesale on every geometry+ // change, so a pick has to be re-applied from state afterwards, and one path+ // plus one head per edge is what every other reader of this layer counts on.+ const p=document.createElementNS('http://www.w3.org/2000/svg','path');p.setAttribute('d',depPath(x1,y1,x2,y2));+ p.setAttribute('data-from',depId);p.setAttribute('data-to',r.id);depSvg.appendChild(p);+ const a=document.createElementNS('http://www.w3.org/2000/svg','polygon');a.setAttribute('points',`${x2},${y2} ${x2-6},${y2-3} ${x2-6},${y2+3}`);+ a.setAttribute('data-from',depId);a.setAttribute('data-to',r.id);depSvg.appendChild(a); maxY=Math.max(maxY,y1,y2);});}); depSvg.setAttribute('height',Math.max(maxY+40,bars.scrollHeight));+ // The redraw threw away the classes, so the pick and the armed state are put back+ // from the state that outlives the DOM.+ paintDepSel(); } function wireDepRedraw(){ setTimeout(drawDeps,100);new ResizeObserver(drawDeps).observe(bars);@@ -3467,8 +3591,16 @@ bars.addEventListener('click',e=>{ if(depMode){ e.stopPropagation(); const b=e.target.closest('.bar[data-id]');- if(b){ depClickBar(b.dataset.id); return; }+ if(b){ setDepSel(null); depClickBar(b.dataset.id); return; } if(e.target.closest('.ms-hit, .ms-cap')){ flash(saveInd,'milestones cannot be dependencies here',2600); return; }+ // Nothing solid under the pointer, so ask the arrows. A half-made pair and a picked+ // arrow are two answers to the same question and cannot both be live, so picking+ // one drops the other.+ const edge=depEdgeAt(e.clientX,e.clientY);+ if(edge){ setDepPending(null); setDepSel(edge); return; }+ // Genuinely empty space: both let go, which is the same thing Escape does one rung+ // at a time and the thing a click away from everything should mean.+ setDepPending(null); setDepSel(null); return; } const msHit=e.target.closest('.ms-hit, .ms-cap');@@ -4295,6 +4427,11 @@ document.addEventListener('mouseup',()=>{ // listed and would not survive an export. Rather than invent an id namespace for // milestones inside this feature, the mode says so and leaves them alone. let depMode=false, depPending=null;+// A picked ARROW, and an arrow armed for removal. Both are {from,to}: `from` is the+// task that comes first (the master) and `to` is the one that waits on it, which is+// the row whose `deps` array holds the edge. Both live only in the mode.+let depSel=null, depArm=null;+function sameDep(a,b){ return !!(a&&b&&a.from===b.from&&a.to===b.to); } function depIsEligible(r){ return !!(r && r.id && r.type!=='milestone'); } // Deps are "needs" edges: r.deps lists what r waits on. Adding master to dep.deps // makes dep wait on master, which closes a loop if master already waits on dep,@@ -4320,18 +4457,67 @@ function setDepPending(id){ if(depPending){const b=barEls[depPending];if(b)b.bar.classList.add('dep-pending');} syncDepCaption(); }+// ── Picking a dependency arrow ──+//+// Only in the mode. In the normal lens the arrows are chrome: they cross bars, names+// and empty grid, and a layer that took clicks there would steal them from the plan+// underneath. So the layer stays pointer-events:none in EVERY mode and picking an+// arrow is a hit TEST rather than a hit target: a click that landed on nothing else is+// measured against the routes the browser actually drew, and the nearest one within+// DEP_PICK px is picked. A bar therefore always wins over an arrow crossing it, which+// is the right precedence, since the bar is the thing and the arrow is only the line+// between things, and normal mode is untouched by construction rather than by a CSS+// condition that a later round could get wrong.+const DEP_PICK=9;+function depEdgeAt(clientX,clientY){+ if(!depSvg||depSvg.classList.contains('hidden'))return null;+ const box=depSvg.getBoundingClientRect();+ const x=clientX-box.left, y=clientY-box.top;+ let best=null,bd=DEP_PICK;+ depSvg.querySelectorAll('path[data-from]').forEach(el=>{+ const L=el.getTotalLength(); if(!L)return;+ // Sampled about every 6px, floored and capped so a very short route is still+ // measured properly and a very long one cannot cost more than a few hundred points.+ const n=Math.max(24,Math.min(300,Math.round(L/6)));+ for(let i=0;i<=n;i++){+ const q=el.getPointAtLength(L*i/n);+ const d=Math.hypot(q.x-x,q.y-y);+ if(d<bd){bd=d;best=el;}+ }+ });+ return best?{from:best.getAttribute('data-from'),to:best.getAttribute('data-to')}:null;+}+// The pick and the armed state are properties of the plan, not of the SVG, because+// drawDeps throws the whole layer away on every geometry change. This is what puts+// them back, and drawDeps calls it as its last act.+function paintDepSel(){+ if(!depSvg)return;+ depSvg.querySelectorAll('[data-from]').forEach(el=>{+ const d={from:el.getAttribute('data-from'),to:el.getAttribute('data-to')};+ el.classList.toggle('sel',sameDep(d,depSel));+ el.classList.toggle('del-armed',sameDep(d,depArm));+ });+}+function setDepSel(sel){+ depSel=sel||null;+ disarmDepDelete();+ paintDepSel();+ syncDepCaption();+} function syncDepCaption(){ const cap=$('depCaption'); if(!cap)return; document.documentElement.setAttribute('data-dep',depMode?'on':'off'); const btn=$('btnDeps'); if(btn)btn.classList.toggle('on',depMode); if(!depMode){cap.innerHTML='';return;}- cap.innerHTML=depPending- ? `Now click the task that waits on <b>${esc(depName(depPending))}</b>. <kbd>Esc</kbd> clears the pick, <kbd>Esc</kbd> again leaves.`- : `Adding dependencies. Click the task that comes <b>first</b>, then the one that waits on it. <kbd>Esc</kbd> leaves the mode.`;+ cap.innerHTML=depSel+ ? `Arrow picked: <b>${esc(depName(depSel.to))}</b> needs <b>${esc(depName(depSel.from))}</b>. <kbd>Delete</kbd> twice removes it, <kbd>Esc</kbd> lets it go.`+ : depPending+ ? `Now click the task that waits on <b>${esc(depName(depPending))}</b>. Clicking one that already does removes that link. <kbd>Esc</kbd> clears the pick, <kbd>Esc</kbd> again leaves.`+ : `Adding dependencies. Click the task that comes <b>first</b>, then the one that waits on it. Click an arrow to pick it. <kbd>Esc</kbd> leaves the mode.`; } function setDepMode(on){ depMode=!!on;- if(!depMode)setDepPending(null);+ if(!depMode){setDepPending(null);setDepSel(null);} document.body.classList.toggle('dep-mode',depMode); if(depMode){closeEditor();closeMenus();clearSelection();} syncDepCaption();@@ -4347,7 +4533,13 @@ function depClickBar(id){ const masterId=depPending, depId=id; const dep=items.find(d=>d.id===depId); if((dep.deps||[]).includes(masterId)){- flash(saveInd,'"'+depName(depId)+'" already needs "'+depName(masterId)+'"',2800);+ // The same pair again means the opposite. Until 0.39.0 this branch said "already+ // needs" and did nothing, which left removing a link as a job for the editor only:+ // the mode could add an edge but never take one back, so the one gesture that+ // obviously means "not that" was the one gesture it refused. It removes now, down+ // the same removeDep the editor's x goes down, so there is one removal in this app+ // however it is reached.+ removeDep(masterId,depId,'dep-mode'); setDepPending(null); return; } // The data model is a DAG and everything downstream assumes it, so a loop is@@ -4371,6 +4563,69 @@ function depClickBar(id){ // The pair resets and the mode stays on: the next click starts the next pair. setDepPending(null); }+// ── Removing one dependency, from wherever it was asked for ──+//+// The editor's x has done this since 0.10.0. This is the same three lines, so the two+// callers leave the SAME undo entry (one edit snapshot, which already carries the deps+// array, hence one Ctrl+Z and redo for free) and the server derives the same+// dep.remove event from the same state change. Only `via` differs, so the changelog+// still says which gesture it was.+function removeDep(masterId,depId,via){+ const dep=items.find(d=>d.id===depId);+ if(!dep||!(dep.deps||[]).includes(masterId))return false;+ snapshot(depId);+ dep.deps=dep.deps.filter(d=>d!==masterId);+ // A removed edge cannot stay picked: its route is about to stop existing.+ if(sameDep(depSel,{from:masterId,to:depId}))setDepSel(null);+ updateBar(depId);+ drawDeps();+ if(prefs.view==='table')renderTable();+ undoToast.innerHTML='Removed dependency: '+esc(depName(depId))+' no longer needs '+esc(depName(masterId))+' · <kbd>Ctrl+Z</kbd>';+ showToast(undoToast,4500,()=>{undoToast.innerHTML=UNDO_TOAST_DEFAULT;});+ scheduleSave(via||'dep-mode');+ return true;+}+// ── Delete on a picked arrow, armed the way the chart's row Delete is ──+//+// Same shape and for the same reason: the key has no cursor to aim it, so the first+// press says what would go and marks it, and the second inside the window does it.+// Everything else withdraws the question through the two capturing listeners in+// wireGlobalKeys, which watch both arms.+function disarmDepDelete(){+ if(!depArm)return;+ clearTimeout(depArm.timer);+ depArm=null;+ paintDepSel();+ if(undoToast.__armed){ undoToast.__armed=false; undoToast.classList.remove('show'); undoToast.innerHTML=UNDO_TOAST_DEFAULT; }+}+function armDepDelete(sel){+ disarmDepDelete();+ depArm={from:sel.from,to:sel.to,timer:setTimeout(disarmDepDelete,4000)};+ paintDepSel();+ undoToast.__armed=true;+ undoToast.innerHTML='Press <kbd>Delete</kbd> again to remove this dependency';+ showToast(undoToast,4000,()=>{undoToast.__armed=false;undoToast.innerHTML=UNDO_TOAST_DEFAULT;disarmDepDelete();});+}+// Live only while an arrow is actually picked in the mode, and only when nothing else+// holds the keyboard. Same gate as the chart's row Delete, minus the selection clause:+// this one acts on the arrow, not on the rows.+function depDeleteReady(){+ if(!depMode||!depSel)return false;+ if(prefs.view==='table')return false;+ if(tblEditing||labelEditing||rowDrag||laneDrag||dragState||reorderState||gesture)return false;+ if(datePop&&datePop.classList.contains('show'))return false;+ if(isTypingTarget(document.activeElement))return false;+ if(editorEl.classList.contains('show'))return false;+ return true;+}+function depDeleteKey(e){+ e.preventDefault();+ if(!sameDep(depArm,depSel)){ armDepDelete(depSel); return; }+ const from=depSel.from, to=depSel.to;+ disarmDepDelete();+ setDepSel(null);+ removeDep(from,to,'dep-arrow-delete');+} function wireDepMode(){ $('btnDeps').addEventListener('click',e=>{e.stopPropagation();toggleDepMode();}); }@@ -5226,6 +5481,46 @@ function showEditor(x,y,square){ editorEl.classList.add('show'); placeEditor(x,y); }+// ── One Tab per field in the panels, the way the table already walks a row ──+//+// A date input is three controls to the browser, so a plain Tab walks day, month and+// year before it reaches the next field: FOUR presses to cross one date, measured, and+// eight to cross a start and a deadline. The table answered this in 0.22.2 by claiming+// Tab in the capture phase, before the input's own default action can run, and the+// panels get the same answer so the two lenses tab alike.+//+// Only Tab, and only OUT OF a date field. Inside one the arrows still move between+// segments and still change the value, and typing still fills them, because none of+// those keys are touched. Every other field keeps the browser's own walk, which was+// already one stop per field, so this adds nothing to the fields that did not need it.+// Every panel in this app renders into the same element, so one listener covers the+// task editor, the milestone editor, the full item editor, the create panel and the+// batch panel, and any panel added later is covered before it is written.+function edTabStops(){+ return [...editorEl.querySelectorAll('input, select, textarea, button')]+ .filter(el=>!el.disabled && el.tabIndex>=0 && el.offsetParent!==null);+}+function wireEditorTab(){+ editorEl.addEventListener('keydown',e=>{+ if(e.key!=='Tab'||e.altKey||e.ctrlKey||e.metaKey)return;+ const ae=document.activeElement;+ if(!ae||ae.tagName!=='INPUT'||ae.type!=='date')return;+ if(!editorEl.contains(ae))return;+ const stops=edTabStops();+ const i=stops.indexOf(ae);+ if(i<0)return;+ const next=stops[i+(e.shiftKey?-1:1)];+ // At either end of the panel's own cycle the browser's walk is the right one:+ // it is the way out of the panel and this has no opinion about that.+ if(!next)return;+ e.preventDefault();e.stopPropagation();+ next.focus();+ // A text field is landed on the way Tab lands on one, with its contents selected.+ // A date field is not: select() on one throws in Blink, and there is nothing to+ // select anyway since it lands on its first segment.+ if(next.tagName==='INPUT'&&next.type!=='date'&&next.select)next.select();+ },true);+} function wireEditorDrag(){ editorEl.addEventListener('mousedown',e=>{ // Only the grab strip starts a drag, so preventDefault (which is what stops the@@ -6609,6 +6904,7 @@ async function init(){ wireDatePicker(); wireLabelColumnResize(); wireEditorDrag();+ wireEditorTab(); restoreTableSort(); wireLaneDrag(); wireTableColumnResize();
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.38.0",+ "version": "0.39.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view 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. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. 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+2−2@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.38.0",+ "version": "0.39.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view 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. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. 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": {@@ -63,4 +63,4 @@ "name": "Drew Owens", "email": "[email protected]" }-}+}