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.46.0: in dependency mode the row under the pointer lights up in its own phase colour, both panes together
6 files changed
+351−6
README.md+1−1@@ -237,7 +237,7 @@ 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. 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+D | Dependency mode: click the task that comes first, then the one that waits on it. 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 | | 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 |
SKILL.md+1−1@@ -229,7 +229,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 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. 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+- **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 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. 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. 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 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
docs/BUILD-NOTES.md+199@@ -3,6 +3,205 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-20 (0.46.0, the row under the pointer in dependency mode)++One change from Drew, and the whole of it is visual. `gantt.html` only. `server.js` did+not move: nothing about the sidecar, the changelog or the save protocol changed. **No+live server was restarted and no live plan was opened at any point.** The suites run on+synthetic fixtures on their own ports.++The complaint was specific. In dependency mode you are asked to click a particular BAR,+and a bar is 20px of colour in a 34px row with the name that identifies it a whole pane+away. Between rows, or a lane below the one you meant, there was nothing on the screen+saying which row the pointer was actually on, so the mode's one gesture was the one+gesture the chart did not help you aim.++### The row lights, both halves of it, and only in the mode++The row under the pointer takes a background wash across its full width: the name cell+in the label pane and the band in the chart, together, so the eye can join a name to a+bar without moving the pointer to check. Exactly one row wears it at a time.++**It is armed by the mode and by nothing else.** `depHoverMove` leaves on its first line+when `depMode` is off, `setDepMode(false)` clears whatever was lit, and the CSS rule is+under `body.dep-mode`, so the normal lens is untouched by construction rather than by a+condition somebody could get wrong later. That is asserted rather than asserted-to: see+the A/B below.++Nothing here is a hit target. Dependencies are still added by clicking task bars, the+click handling did not change a line, and the wash is guidance and not a control.++### The colour is the row's own phase, and that is the whole idea++A neutral grey band would have said "some row". The phase wash says "this row, in this+lane", which is the sentence the mode actually needs, because the phase is how the chart+is read while you are pairing tasks across lanes.++The tint comes from `phaseColor(r.phase)`, which is the same call the bar's fill comes+from, so both kinds of lane colour arrive correctly and for free:++- a lane somebody **painted** hands back an explicit hex, which is theme-invariant by+ design, so its wash is the same hue in dark, light and print;+- a lane following the **ramp** hands back `var(--ph1..3)`, so its wash changes with the+ theme exactly as its bars do.++`phaseColor` returns a CSS reference and not numbers, and a CSS variable cannot be+interpolated into an `rgb()` alpha, so `cssRgbTriple` resolves it once per colour per+theme and the element carries the triple:++```+--dep-hl-rgb: 0 184 177; set on the two row divs by setDepHover+--dep-hl-a: 0.14 dark / 0.10 light; a THEME token, never touched by JS+background: rgb(var(--dep-hl-rgb) / var(--dep-hl-a));+```++Splitting it that way is what lets a theme switch retint every row with no JS at all,+and what lets the two themes be tuned apart, which they had to be. The ramp cycles three+slots, so a fifth lane shares `--ph2` with the second one and washes identically: the+wash reads the COLOUR, never the phase index. The v46 fixture carries a lane at index 4+for exactly that reason.++Milestone rows wash the same way. A milestone has no bar, so its tint cannot have come+from one; it comes from the same `phaseColor(r.phase)` its dashed rule and its cap+already use.++### Two themes, two alphas, and the arithmetic that set them++The bar has to stay clearly readable on the row it is sitting on, so the alpha is not a+taste question. Composited and measured, fill over wash against the wash itself:++| theme | lane | fill | wash | bar/wash | bar on the bare page | kept |+|---|---|---|---|---|---|---|+| dark | ramp 1 | `rgb(0,184,177)` @0.82 | `rgb(11,40,45)` | **4.68:1** | 5.46:1 | 85.7% |+| dark | ramp 2 | `rgb(140,107,247)` @0.82 | `rgb(31,30,54)` | **3.34:1** | 3.74:1 | 89.5% |+| dark | ramp 3 | `rgb(100,171,255)` @0.82 | `rgb(25,39,55)` | **4.78:1** | 5.67:1 | 84.3% |+| dark | painted | `rgb(224,102,58)` @0.82 | `rgb(43,29,28)` | **3.64:1** | 4.05:1 | 89.8% |+| light | ramp 1 | `rgb(0,164,157)` @1 | `rgb(230,246,245)` | **2.77:1** | 3.09:1 | 89.7% |+| light | ramp 2 | `rgb(140,107,247)` @1 | `rgb(244,240,254)` | **3.37:1** | 3.78:1 | 89.3% |+| light | ramp 3 | `rgb(59,149,255)` @1 | `rgb(235,244,255)` | **2.74:1** | 3.03:1 | 90.4% |+| light | painted | `rgb(224,102,58)` @1 | `rgb(252,240,235)` | **3.06:1** | 3.43:1 | 89.4% |++**The floor is 2.5:1, not 3:1, and that is a fact about the light palette rather than a+concession by this change.** A light-theme bar is opaque on white and clears only 3.03:1+against the bare page to begin with, so NO wash of any strength can be held to 3:1 there;+a 3:1 floor would be a floor on the 0.45.0 palette, failed by a change that had not yet+been made. What is asked instead is that the wash costs the bar almost nothing of what it+had: the suite asserts 2.5:1 absolute AND 80% of the same bar's contrast against the+unwashed page, and the worst case measured is 84.3%.++The two alphas fall straight out of that. Dark can afford 0.14, which is over three times+the 0.04 neutral the same row uses for an ordinary hover, so the band is unmistakable.+Light gets 0.10 for the reason in the table: it starts with about a third of the headroom+and every step the row takes towards the phase is a step the bar loses.++### The transition, which was a real bug rather than a polish item++`.label-row` fades its background over 150ms and `.bar-row` does not. Left alone, the two+halves of one row lit at different times, and the join the whole feature is FOR was the+thing the eye missed. In the mode the name cell drops the background half of that+transition and keeps the opacity half, which a filtered row still needs:++```+body.dep-mode .label-row { transition: opacity 0.15s; }+```++It also means a fast sweep leaves no comet trail of rows fading out behind the pointer.+One row is lit, and only one. The first probe caught this: the label pane read+`rgba(0,184,177,0.047)` where the chart band already read `0.14`.++### The armed source keeps its ring++0.40.0 settled that arming the first task of a pair IS selecting it, and gave both the+same outline. That is untouched. The ring is an `outline` drawn on the BAR and the wash+is a `background` on the row behind it, so the ring is above the wash by construction and+not by a z-index anybody has to maintain. Measured on an armed and hovered row: outline+still `solid`, still 2px, still `rgb(0,184,177)`. The picked-arrow amber and the+armed-for-removal red from 0.39.0 are not involved at all.++### The throttle is an identity check, not a timer++A sweep spends most of its moves inside the row it is already on. Those cost one+`closest()` and one comparison, and touch neither the DOM nor the style system. Only a+row CHANGE repaints, and a repaint is two class toggles and two custom properties, never+a per-row restyle. The remembered element is kept even when it resolves to nothing+paintable, so hovering a lane header is just as cheap as hovering a row.++No timer and no rAF, deliberately. The highlight has to be where the pointer is by the+time the pointer stops, and a coalescing frame would have put a "wait one frame" into+every assertion about it.++### The rows can move while the pointer does not++This one was found by probing rather than by reading, and it is the failure the whole+change exists to prevent, arriving by the back door. Scroll the chart with the pointer+parked and no mousemove fires, so the wash stayed on the row that scrolled away while the+click would have gone to the row that slid under the pointer. Measured before the fix:+the wash on `A1 first`, the pointer over `B1 first`. A lane folding up above the pointer+does the same thing.++`depHoverResync()` asks the question again from the last place the pointer was, and it is+wired to the two scrollers (scroll does not bubble, so the timeline and the label column+are listened to directly) and to the lane toggle. It uses `elementsFromPoint`, plural, on+purpose: the grid bands and the arrow layer take no pointer events but are still the+topmost things at that coordinate, so the singular call would answer with a layer that+has no row above it and the row underneath would look like open space. A pointer that has+left the chart resets the remembered position, so a resync can never light a row the+pointer is not actually over.++### Verification++`g46/v46.mjs`, 160 assertions, zero JS errors. It reads the computed+style of the rows the browser laid out; nothing reaches into the app's own colour helper+to check the app's own colour helper, and the contrast maths is written a second time in+the suite so the numbers above are an independent account.++- Normal mode, before anything is armed: a sweep across every row in both panes lights+ nothing and leaves no `--dep-hl-rgb` anywhere.+- In the mode, per row and per lane kind (three ramp slots, one painted lane, one lane+ past the ramp wrap, three milestone rows, from both panes): exactly two elements lit,+ both halves of the same row, the triple equal to the lane fill, the computed background+ equal to `rgba(fill, alpha)` exactly, and the composited tint within 15 degrees of the+ lane hue (worst measured 11.8, and it is the blue-tinted dark page that drags it).+- The highlight follows the pointer across six rows in order, one row at a time.+- A lane header, the open space below the last row, and leaving the chart: nothing lit.+- Armed source: ring present and unwashed under the wash on its own row, and still+ present after the pointer moves to another row that has taken the wash.+- 100 moves in a burst: no errors, exactly one row lit, and it is the row the sweep ended+ on, with no orphan variable on any other row.+- The rows moving under a parked pointer: a scroll puts the wash on whatever row is+ genuinely under the pointer afterwards (resolved by hit-testing, independently of the+ app's row list), scrolling back returns it, a lane folding away does not strand it, and+ unfolding puts it back.+- Every exit path, one at a time: Escape (both rungs, so dropping a half-made pick keeps+ the mode AND the highlight, and the next Escape takes both), the Dependencies button,+ and switching to the table lens. In each case the highlight is gone immediately and no+ row keeps the variable. Completing a pair keeps the mode on, as it always has, and the+ row under the pointer stays guided for the next pick.+- Milestone style cycled mid-mode: a milestone row washes in "both" style, and cycling to+ line style, where a milestone has no row at all, clears rather than strands.+- Contrast measured for every lane in both themes, per the table above.++`g46/ab46.sh` is the A/B. Both builds on the same fixture, each on its own port and its+own copy of the data, recording every row of both panes (classes, inline style, computed+background, transition, opacity, box-shadow) and every bar (class, fill, opacity,+outline), at rest and after a full pointer sweep of both panes, in both themes:++```+NORMAL LENS A/B (0.45.0 vs 0.46.0): BYTE IDENTICAL+b762e2835d6907d8a7ba708c7c7bff91 ab45-rows.out+b762e2835d6907d8a7ba708c7c7bff91 ab46-rows.out+```++Two false differences had to be taken out of that recording first, and both were the same+mistake: sampling a transition in flight. The bars cross-fade between the two themes'+`--bar-opacity` over 150ms, and a row's ordinary hover background fades over the same+150ms, so a reading taken 160ms after a click records the instant it was taken rather+than the chart. Both builds do it, at slightly different instants. The recording settles+before every shot now.++The standing battery is green on the source build, and the two fixture-coupled legacy+suites are byte-identical between the builds.+ ## Build notes, 2026-08-19 (0.45.0, federal holidays, merged per-person card, remaining hours) Three changes from Drew, all in the Analytics lens and the calendar. `gantt.html` only.
gantt.html+147−1@@ -75,6 +75,13 @@ at a glance rather than as one shade of grey. */ --holiday: rgba(255,176,102,0.085); --row-hover: rgba(255,255,255,0.04);+ /* How hard the dependency-mode row wash tints. The COLOUR is the row's own phase,+ set on the element; only the strength is a theme token, and it has to be one per+ theme because the two grounds leave the bar different amounts of room. On this+ dark page a bar is drawn at 0.82 over near-black, so a tint this side of the+ phase costs it very little and 0.14 is what makes the band unmistakable next to+ the 0.04 neutral the same row already uses for an ordinary hover. */+ --dep-hl-a: 0.14; /* Phase ramp: teal -> purple -> blue, then a lighter step of each. Used when a phase declares no explicit colour. */ --ph1: #00b8b1; --ph2: #8c6bf7; --ph3: #64abff;@@ -108,6 +115,11 @@ --weekend: rgba(31,35,40,0.04); --holiday: rgba(199,105,10,0.105); --row-hover: rgba(31,35,40,0.04);+ /* Lighter on paper, and it has to be: a light-theme bar is opaque and sits on white,+ so it starts with about a third of the headroom the dark theme's does, and every+ step the row takes towards the phase is a step the bar loses. Measured, 0.10 keeps+ the ramp bars within a tenth of the contrast they have against the bare page. */+ --dep-hl-a: 0.10; /* Deepened for paper: each clears 3:1 on white while holding its hue. */ --ph1: #00a49d; --ph2: #8c6bf7; --ph3: #3b95ff; --bar-opacity: 1;@@ -459,6 +471,29 @@ body.col-resizing { cursor: col-resize; user-select: none; } /* 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; }+/* The name cell fades its background over 150ms for the ordinary hover, and the chart+ band does not, so left alone the two halves of one row would light at different times+ and the join the wash below is FOR would be the thing the eye missed. In the mode the+ name cell drops the background half of that transition and keeps the opacity half,+ which a filtered row still needs. It also means a fast sweep leaves no comet trail of+ rows fading out behind the pointer: one row is lit, and only one. */+body.dep-mode .label-row { transition: opacity 0.15s; }+/* The row under the pointer, in dependency mode and nowhere else. The mode asks for a+ click on a particular BAR, and a 20px bar in a 34px row has its name a whole pane+ away, so the name cell and the chart band light TOGETHER and the eye can join the+ two without moving the pointer.++ The wash is the row's own phase, not a neutral: in this mode the phase is how the+ chart is read, and a grey band would say "some row" where this says "this row, in+ this lane". Only the triple is set on the element (by setDepHover, from the same+ phaseColor the bar is filled with, so an explicit lane colour and a ramp slot both+ arrive as numbers); the ALPHA stays a theme token, which is what lets a theme+ switch retint every row without JS and lets the two themes be tuned apart.++ It is a background, and the armed source's ring is an OUTLINE drawn on the bar, so+ the ring is above the wash by construction rather than by a z-index. */+body.dep-mode .label-row.dep-hover,+body.dep-mode .bar-row.dep-hover { background: rgb(var(--dep-hl-rgb) / var(--dep-hl-a)); } /* ── 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); }@@ -1315,6 +1350,9 @@ labelsEl.addEventListener('click',e=>{ e.stopPropagation(); const g=items.find(r=>r._laneBtn===btn); if(g)setCollapsed(g.phase,!isCollapsed(g.phase));+ // A lane folding away above the pointer slides a different row under it without+ // moving it, which is the same stale-row problem a scroll causes and the same answer.+ depHoverResync(); }); } @@ -5245,12 +5283,106 @@ function syncDepCaption(){ } function setDepMode(on){ depMode=!!on;- if(!depMode){setDepPending(null);setDepSel(null);}+ if(!depMode){setDepPending(null);setDepSel(null);clearDepHover();} document.body.classList.toggle('dep-mode',depMode); if(depMode){closeEditor();closeMenus();clearSelection();} syncDepCaption(); } function toggleDepMode(){ setDepMode(!depMode); }+// ── The row under the pointer ──────────────────────────────────────────────────+//+// Armed by the mode and by nothing else. In the normal lens a row already answers+// "which one" the moment you are over its bar, because the bar is what you are aiming+// at; in this mode the target is a bar you are about to PAIR, the pointer is often+// between rows on its way somewhere, and the name that identifies the row is in the+// other pane. So the mode, and only the mode, paints the band.+//+// Purely visual: the click that adds a dependency is still the click on the bar it has+// always been, and nothing here is a hit target.+let depHoverEl=null, depHoverItem=null;+// Resolving a fill costs a getComputedStyle, so it is done once per colour per theme+// rather than once per pointer move. phaseColor hands back an explicit hex for a lane+// somebody painted (theme-invariant by design) and a var(--phN) for one that follows+// the ramp, so both forms have to come out as three numbers.+const depRgbMemo=new Map();+function cssRgbTriple(css){+ const key=(document.documentElement.getAttribute('data-theme')||'')+'|'+String(css||'');+ if(depRgbMemo.has(key))return depRgbMemo.get(key);+ let s=String(css||'').trim();+ const v=s.match(/^var\((--[\w-]+)\)$/);+ if(v)s=getComputedStyle(document.documentElement).getPropertyValue(v[1]).trim();+ let out=null,m=s.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);+ if(m){+ const h=m[1].length===3?m[1].replace(/./g,c=>c+c):m[1];+ out=parseInt(h.slice(0,2),16)+' '+parseInt(h.slice(2,4),16)+' '+parseInt(h.slice(4,6),16);+ }else if((m=s.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i))){+ out=Math.round(+m[1])+' '+Math.round(+m[2])+' '+Math.round(+m[3]);+ }+ depRgbMemo.set(key,out);+ return out;+}+function clearDepHover(){+ if(depHoverItem){+ [depHoverItem._lDiv,depHoverItem._bDiv].forEach(el=>{+ if(!el)return;+ el.classList.remove('dep-hover');+ el.style.removeProperty('--dep-hl-rgb');+ });+ }+ depHoverEl=null;depHoverItem=null;+}+// Exactly one row wears the wash, so painting is: take the old one off, put the new one+// on. A lane header is not a row of work and gets nothing, which is also the answer for+// empty space below the last row.+function setDepHover(r){+ clearDepHover();+ if(!r||r.type==='group')return;+ const rgb=cssRgbTriple(phaseColor(r.phase));+ if(!rgb)return;+ [r._lDiv,r._bDiv].forEach(el=>{+ if(!el)return;+ el.style.setProperty('--dep-hl-rgb',rgb);+ el.classList.add('dep-hover');+ });+ depHoverItem=r;+}+// The throttle is the identity check, not a timer: a sweep spends most of its moves+// inside the row it is already on, and those cost one closest() and one comparison and+// touch neither the DOM nor the style system. The element is remembered even when it+// resolves to nothing paintable, so hovering a lane header is just as cheap.+let depHoverX=-1, depHoverY=-1;+function depHoverMove(e){+ if(!depMode)return;+ depHoverX=e.clientX; depHoverY=e.clientY;+ const el=e.target&&e.target.closest?e.target.closest('.label-row, .bar-row'):null;+ if(el===depHoverEl)return;+ setDepHover(el?items.find(x=>x._lDiv===el||x._bDiv===el):null);+ depHoverEl=el;+}+// The rows can move under a pointer that has not moved: a scroll, or a lane collapsing+// above it. Nothing fires a mousemove for that, so the wash would sit on the row the+// pointer USED to be over while the click went to the row that slid under it, which is+// the one thing this feature exists to stop. So the question gets asked again from the+// last place the pointer was.+//+// elementsFromPoint rather than elementFromPoint: the grid bands and the arrow layer+// take no pointer events, but they are still the topmost things at that coordinate, so+// the singular call answers with a layer that has no row above it and the row underneath+// would look like empty space.+function depHoverResync(){+ if(!depMode||depHoverX<0)return;+ for(const el of document.elementsFromPoint(depHoverX,depHoverY)){+ const row=el.closest&&el.closest('.label-row, .bar-row');+ if(!row)continue;+ if(row===depHoverEl)return;+ setDepHover(items.find(x=>x._lDiv===row||x._bDiv===row));+ depHoverEl=row;+ return;+ }+ clearDepHover();+}+// The pointer has left the chart, so there is no last position inside it to resync to.+function depHoverLeave(){ depHoverX=-1; depHoverY=-1; clearDepHover(); } // One click in the mode. Returns nothing; every refusal says why in the same place // every other refusal in this app says why. function depClickBar(id){@@ -5356,6 +5488,20 @@ function depDeleteKey(e){ } function wireDepMode(){ $('btnDeps').addEventListener('click',e=>{e.stopPropagation();toggleDepMode();});+// One listener on the pane that holds BOTH columns, so a pointer crossing the+// resizer from names to bars is one continuous hover rather than two. The handler+// leaves immediately when the mode is off, which is what keeps the normal lens+// exactly what it was.+const wrap=timelineEl.closest('.chart-wrap')||timelineEl.parentElement;+if(wrap){+ wrap.addEventListener('mousemove',depHoverMove);+ wrap.addEventListener('mouseleave',depHoverLeave);+}+// Scroll does not bubble, so it is taken on the two scrollers themselves. Both are+// listened to rather than just the timeline, because the label column is its own+// scroller even though it mirrors.+timelineEl.addEventListener('scroll',depHoverResync);+labelsEl.addEventListener('scroll',depHoverResync); } // ── Bar notes ──
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.45.0",+ "version": "0.46.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, and one more lights up the critical path. 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. 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.45.0",+ "version": "0.46.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, and one more lights up the critical path. 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. 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": {@@ -85,4 +85,4 @@ "name": "Drew Owens", "email": "[email protected]" }-}+}