← Commit history

0.22.3: four small fixes, one interesting cause

Drew Owens ·d93e8545c2 · ·parent 4ee6b98
The milestone tags and the dashed rules were the same one declaration. A tag had neighbouring milestones' rules drawn through its text. .ms-line carried opacity: 0.75, and that did two damaging things at once: it made the tag's own background 75% transparent so anything behind showed through, AND it created a stacking context, trapping the tag inside its own rule's layer so a rule from a milestone later in the DOM painted over a tag earlier in it. No z-index on the tag could have escaped that. The rule is a ::before now carrying its own opacity; the container carries neither an opacity nor a z-index, so rules and tags share one stacking context at 4 and 7. The today line joins the rules at 5 and its tag stays on top at 9. Verified by sampling: 594 points across all nine caps, every one reporting the cap as the top element, at 100% and 150% scale, both themes. The chevron counts reported the data file, not the plan: applyCollapse() writes them and ran during the build, before applyState() merged the sidecar, so a lane of 77 advertised 46. Called after the merge now, and at the top of applyFilter(), the choke point every structural change already goes through. 77/20/14 on load, 78 on a create, back to 77 on a delete. The SVG export had no colours at all. generateSVG read p.color, but the app returns var(--ph1..3) so a theme switch recolours without a rebuild, and a data file's phases carry no colour, so every fill was the literal string 'undefined': 107 of them. The export resolves the same ramp itself now, honours an explicit p.color, and ignores a var() if it finds one. Zero undefined, three phase bands, explicit colours honoured. Today is grey. Teal means interactive in this brand and the phase ramp already colours the milestone rules, so a permanent teal reference line competed with both. It reads --text2 through the --today token, so line, tag text and legend swatch moved together and both themes stayed right with one change. The line stays SOLID rather than dashed, deliberately: it has never been dashed, and now that colour no longer separates it from the milestone rules, solid versus dashed is the only thing that does.
5 files changed +93−14
docs/BUILD-NOTES.md+45
@@ -3,6 +3,51 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-11 (0.22.3, four small fixes with one interesting cause)++**The milestone tags and the dashed rules were the same one declaration.** A tag like+"Capitol Hill reception" had neighbouring milestones' rules drawn straight through its+text. `.ms-line` carried `opacity: 0.75`, and that did two separate damaging things at+once: it made the tag's own `--bg` background 75% transparent, so anything behind it+showed through, AND it created a stacking context, which trapped the tag inside its own+rule's layer so a rule belonging to a milestone later in the DOM painted over a tag+earlier in it. No z-index on the tag could have escaped that. The rule is drawn by a+`::before` now, which carries the opacity on its own; the container carries neither an+opacity nor a z-index, so the rules and the tags share one stacking context and sit at+4 and 7, where a z-index finally means something between them. The today line joins the+rules underneath at 5 and its own tag stays on top at 9. Verified by sampling, not by+eye: 594 points across the full width and height of all nine caps, every one reporting+the cap itself as the top element, at 100% and 150% text scale, in both themes, with+diamond style correctly showing no caps at all.++**The chevron counts were reporting the data file, not the plan.** `applyCollapse()`+writes them and it ran during the build, before `applyState()` merged the sidecar, so a+lane with 77 rows advertised 46. It is called after the merge now, and also at the top+of `applyFilter()`, which is the choke point every structural change already goes+through: add, delete, restore, phase move, drag and keyboard move. That is one line+instead of seven call sites, the seventh of which a later round would have forgotten.+Counts now read 77, 20 and 14 on load, go to 78 on a create and back to 77 on a delete.++**The SVG export had no colours at all.** `generateSVG` read `p.color`, but the app+returns `var(--ph1..3)` rather than a hex precisely so a theme switch recolours without+a rebuild, and a data file's phases carry no colour of their own, so every fill in the+export was the literal string "undefined": 107 of them in the DC plan. There is no+stylesheet in a standalone SVG to resolve a variable against, so the export resolves+the same three-colour ramp itself, honours an explicit `p.color` when a plan has one,+and ignores a `var(...)` if it finds one there. Zero "undefined" now, three phase bands+drawn, explicit colours honoured and a `var()` phase correctly falling back.++**Today is grey.** Teal means interactive or important in this brand, and the phase ramp+already colours the milestone rules, so a permanent teal reference line was competing+with both. It reads `--text2` now, through the `--today` token, so the line, the tag+text and the legend swatch all moved together and both themes stayed correct with one+change: `#8b949e` dark, `#59636e` light. The teal glow and the teal tag border went with+it. **The line stays SOLID rather than dashed**, deliberately and contrary to the+request as written: the today line has never been dashed, and now that colour no longer+separates it from the milestone rules, solid-versus-dashed is the only thing that does.+`--text2` was the pick over `--text-body`; against a `--grid-line` of+`rgba(48,54,61,0.18)` a 2px solid `#8b949e` is not close to being lost.+ ## Build notes, 2026-08-11 (0.22.2, one Tab per column, and three long-lead tasks)  **The native select popup was the whole bug, and it could not be worked around.**
gantt.html+31−8
@@ -36,7 +36,11 @@   --bg: #0d1117; --surface: #161b22; --elevated: #1c2128; --overlay: #21262d;   --border: #30363d; --border-muted: #21262d;   --text: #e6edf3; --text-body: #c9d1d9; --text2: #8b949e; --text3: #484f58;-  --accent: #00b8b1; --accent-hover: #00d4cb; --accent-bright: #00e6dc; --today: #00b8b1;+  --accent: #00b8b1; --accent-hover: #00d4cb; --accent-bright: #00e6dc;+  /* Today is a reference line, not something you act on. Teal means interactive or+     important in this brand, and the phase ramp already colours the milestone rules,+     so today reads as neutral grey and stops competing with both. */+  --today: var(--text2);   --ok: #3fb950; --warn: #d29922;   --on-accent: #05221f;   --grid-line: rgba(48,54,61,0.18);@@ -52,7 +56,7 @@   --bg: #ffffff; --surface: #f6f8fa; --elevated: #ffffff; --overlay: #ffffff;   --border: #d0d7de; --border-muted: #d8dee4;   --text: #1f2328; --text-body: #24292f; --text2: #59636e; --text3: #8c959f;-  --accent: #00857f; --accent-hover: #00a49d; --accent-bright: #00a49d; --today: #00857f;+  --accent: #00857f; --accent-hover: #00a49d; --accent-bright: #00a49d; --today: var(--text2);   --ok: #1a7f37; --warn: #9a6700;   --on-accent: #ffffff;   --grid-line: rgba(31,35,40,0.10);@@ -68,7 +72,7 @@   :root { --bg:#ffffff; --surface:#ffffff; --elevated:#ffffff; --overlay:#ffffff;     --border:#d0d7de; --border-muted:#d8dee4;     --text:#1f2328; --text-body:#24292f; --text2:#59636e; --text3:#8c959f;-    --accent:#00857f; --today:#00857f; --grid-line:rgba(31,35,40,0.10); --weekend:rgba(31,35,40,0.04);+    --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;     color-scheme: light; }   html, body { overflow: visible !important; height: auto !important; }@@ -249,9 +253,18 @@ body.col-resizing { cursor: col-resize; user-select: none; } .ms-hit { position: absolute; top: 0; bottom: 0; width: 16px; transform: translateX(-50%); z-index: 6; cursor: pointer; } .ms-hit:hover ~ .ms-diamond, .ms-row:hover .ms-diamond { box-shadow: 0 0 10px rgba(230,237,243,0.3); } /* Line style: a dashed vertical rule spanning the whole chart at the milestone date. */-.ms-line { position: absolute; top: 0; bottom: 0; width: 0; border-left: 1.5px dashed currentColor; opacity: 0.75; z-index: 4; pointer-events: none; }+/* The dashed rule is drawn by the pseudo-element, not by the container, and the+   container carries neither an opacity nor a z-index. Both of those mattered and+   both were the same one declaration: `opacity` on .ms-line made the tag's own+   background 75% transparent so neighbouring rules showed through the text, AND it+   created a stacking context that trapped the tag inside its own rule's layer, so a+   rule belonging to a milestone drawn later in the DOM painted straight over a tag+   drawn earlier. With the container transparent to stacking, the rules and the tags+   sit in one shared context and a z-index actually means something between them. */+.ms-line { position: absolute; top: 0; bottom: 0; width: 0; pointer-events: none; }+.ms-line::before { content: ''; position: absolute; top: 0; bottom: 0; left: 0; border-left: 1.5px dashed currentColor; opacity: 0.75; z-index: 4; } .ms-line .ms-cap:hover { background: var(--elevated); filter: brightness(1.25); }-.ms-line .ms-cap { position: absolute; top: 2px; left: 0; transform: translateX(-50%); pointer-events: auto; cursor: pointer; font-family: 'JetBrains Mono', monospace; font-size: 9px; line-height: 1.25; color: inherit; background: var(--bg); padding: 2px 4px; border-radius: 3px; border: 1px solid currentColor; white-space: normal; overflow-wrap: anywhere; text-align: center; max-width: 132px; width: max-content; }+.ms-line .ms-cap { position: absolute; z-index: 7; opacity: 1; top: 2px; left: 0; transform: translateX(-50%); pointer-events: auto; cursor: pointer; font-family: 'JetBrains Mono', monospace; font-size: 9px; line-height: 1.25; color: inherit; background: var(--bg); padding: 2px 4px; border-radius: 3px; border: 1px solid currentColor; white-space: normal; overflow-wrap: anywhere; text-align: center; max-width: 132px; width: max-content; } :root[data-ms="diamond"] .ms-line { display: none; } :root[data-ms="line"] .ms-diamond { display: none; } /* In line style the dashed rule and its tag carry the milestone, so the grid row is@@ -260,12 +273,12 @@ body.col-resizing { cursor: col-resize; user-select: none; } :root[data-ms="line"] .ms-hit { display: none; } .label-row.ms::before { content: '\25C6\00a0'; } :root[data-ms="line"] .label-row.ms::before { content: '\00a6\00a0'; }-.today-line { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--today); z-index: 8; pointer-events: none; box-shadow: 0 0 6px rgba(0,184,176,0.3); }+.today-line { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--today); z-index: 5; pointer-events: none; } /* The line itself stays pointer-events:none. A full-height hit strip would sit    over every bar it crosses and eat their clicks, which is a worse trade than a    tooltip. The tag is the marker's affordance, so it carries the tooltip, with a    little more padding to make it a comfortable target. */-.today-tag { position: absolute; top: 4px; transform: translateX(-50%); font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--today); background: var(--bg); padding: 2px 6px; border-radius: 3px; border: 1px solid rgba(0,184,176,0.25); pointer-events: auto; cursor: default; z-index: 9; white-space: nowrap; }+.today-tag { position: absolute; top: 4px; transform: translateX(-50%); font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--today); background: var(--bg); padding: 2px 6px; border-radius: 3px; border: 1px solid var(--border); pointer-events: auto; cursor: default; z-index: 9; white-space: nowrap; } .today-tag:hover { border-color: var(--today); } .dep-svg { position: absolute; top: 0; left: 0; pointer-events: none; z-index: 4; overflow: visible; } .dep-svg path { fill: none; stroke: var(--accent); stroke-width: 1.5; opacity: 0.5; transition: opacity 0.15s; }@@ -2219,7 +2232,13 @@ if(document.fonts&&document.fonts.ready)document.fonts.ready.then(()=>{if(prefs. // ── Filter ── // The legend's phase and milestone toggles are a statement about the plan, not // about the chart, so the table honours them too and the two lenses agree.-function applyFilter(){items.forEach(r=>{let vis=true;if(r.phase!==undefined&&!phaseVis[r.phase])vis=false;if(r.type==='milestone'&&!msVis)vis=false;r._lDiv.classList.toggle('dim',!vis);r._bDiv.classList.toggle('dim',!vis);});drawDeps();if(prefs.view==='table')renderTable();}+// The lane counts on the chevrons are a fact about the plan, so they are refreshed+// wherever the plan's membership can change. applyCollapse() is the thing that+// writes them, and applyFilter() is the choke point every structural change already+// goes through (add, delete, restore, phase move, drag, keyboard move), so putting+// it here covers all of them at once instead of at seven call sites, one of which a+// later round would have forgotten.+function applyFilter(){applyCollapse();items.forEach(r=>{let vis=true;if(r.phase!==undefined&&!phaseVis[r.phase])vis=false;if(r.type==='milestone'&&!msVis)vis=false;r._lDiv.classList.toggle('dim',!vis);r._bDiv.classList.toggle('dim',!vis);});drawDeps();if(prefs.view==='table')renderTable();}  // ── Dependencies ── function sb0(entry){ return entry.row || entry.bar.closest('.bar-row'); }@@ -4462,6 +4481,10 @@ try{const r=await fetch('state');if(r.ok){const st=await r.json();if(!st.empty){ // The sidecar can carry a status or a description that the chart's own label rows // and tooltips were built from the data file with, so restate them. items.forEach(r=>{ if(r.id)refreshLabelRow(r); else if(r.type==='milestone')refreshMsChrome(r); });+// The chevron counts were written during the build, before the sidecar was merged,+// so they reported the data file's lanes and not the plan's. Re-read them now that+// the created rows, the deletions and the phase overrides are all in.+applyCollapse(); // Re-baseline AFTER restoring the sidecar, so replaying saved edits on load is // not mistaken for a fresh round of user changes. lastSnap=snapSemantic();
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.22.2",+  "version": "0.22.3",   "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.22.2",+  "version": "0.22.3",   "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]"   }-}\ No newline at end of file+}
server.js+14−3
@@ -1093,6 +1093,17 @@ function importJiraCSV(body) {  // ── Export: SVG ── +// The app draws a phase in `var(--ph1..3)`, cycling every three lanes, and returns+// the CSS variable rather than a hex so a theme switch recolours everything without+// a rebuild. A standalone SVG has no stylesheet to resolve those against, and the+// phases in a data file carry no `color` of their own, so every fill in this export+// was the string "undefined": 107 of them in the DC plan. These are the dark theme's+// values for the same ramp, used only when a phase has not been given a colour.+const SVG_PHASE_RAMP = ['#00b8b1', '#8c6bf7', '#64abff'];+function svgPhaseColor(p, i) {+  return (p && p.color && !/^var\(/.test(p.color)) ? p.color : SVG_PHASE_RAMP[i % SVG_PHASE_RAMP.length];+}+ function generateSVG() {   const data = mergedData();   const { project, phases } = data;@@ -1200,12 +1211,12 @@ function generateSVG() {   }    // Phase backgrounds-  phases.forEach(p => {+  phases.forEach((p, pi) => {     const [sy, sm] = p.start.split('-').map(Number);     const [ey, em] = p.end.split('-').map(Number);     const x1 = LABEL_W + PAD + mDiff(START, new Date(sy, sm - 1, 1)) * COL;     const x2 = LABEL_W + PAD + (mDiff(START, new Date(ey, em - 1, 1)) + 1) * COL;-    svg += `\n<rect x="${x1}" y="${HEADER_H}" width="${x2 - x1}" height="${totalH - HEADER_H}" fill="${p.color}" opacity="0.04"/>`;+    svg += `\n<rect x="${x1}" y="${HEADER_H}" width="${x2 - x1}" height="${totalH - HEADER_H}" fill="${svgPhaseColor(p, pi)}" opacity="0.04"/>`;   });    // Items@@ -1224,7 +1235,7 @@ function generateSVG() {       const x1 = toX(r.start);       const x2 = toX(r.end);       const w = Math.max(x2 - x1, 6);-      const color = phases[r.phase] ? phases[r.phase].color : '#64ABFF';+      const color = svgPhaseColor(phases[r.phase], r.phase);        // Status tag       const statusColors = { live: '#3fb950', planned: '#64ABFF', 'not-started': '#484f58' };