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.8.6: de-duplicate the header, add a text size control, and give today a tooltip
The header named one project three times: an app-name and a second subtitle in
the brand, then the title in the subject, then a unit count. The brand now
carries the app identity only, and the project's subtitle sits with its title
where a subtitle belongs. Dropped the brand's copy rather than the subject's,
because the subject is where the project is named and the brand should say what
the app is. The header then shed chrome instead of truncating the project name:
under 1460px the unit count, the starts label and the button labels go, icons
and tooltips stay; under 1180px the subtitle goes. The subtitle absorbs all flex
shrink before the title gives up a character. No ellipsis at 1600, 1440, 1280
or 1100.
Text size: A-minus / A-plus with a percentage readout that doubles as the reset,
seven steps from 85% to 150%, persisted and applied before the chart is built.
One CSS variable drives every text size plus the row, bar, axis and diamond
geometry that grows with it, so nothing is rebuilt. Applies to both lenses. The
control itself does not scale and its readout has a fixed width, so pressing it
never moves it.
Two bugs surfaced making that correct rather than merely bigger. Dependency
arrows put their endpoints at offsetTop+17, half of a 34px row hardcoded, so
every arrow was wrong the moment rows grew; they measure the row now. And the
re-measure after a scale change was silently skipped: scheduleRelayout's echo
guard ignores a resize reporting the same pane width and height, which is exactly
this case, so milestone caps kept their old rows and overlapped at 150%.
Today has a tooltip on its tag and its legend entry, recomputed on hover so a
plan left open across midnight is not lying. The line stays pointer-events:none
deliberately: a full-height hit strip would eat the clicks of every bar it
crosses.
6 files changed
+227−16
README.md+4@@ -36,6 +36,8 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau **Move the whole plan.** The project start sits in the header. Change it and every task, milestone and phase shifts by the same number of days, so durations and the gaps between them survive. Revert puts the plan back to the data file's own start. It is one undo step and one changelog entry, not 26. +**Text size.** A-minus and A-plus in the header step the whole app between 85% and 150% in seven steps, chart and table together. The percentage between them is the reset. Rows, bars and the axis grow with the type, so nothing is clipped, and the choice persists.+ **Fit to the pane.** One click on Fit picks the finest cell size that shows the whole plan without scrolling sideways, then stretches the cells to fill the width exactly. Click it again after resizing the window to re-fit. **Cell size.** Days, Weeks, Months, Quarters or Years. Each grain carries its own minimum cell width, sized to the widest label it draws, so a fine grain scrolls rather than squashing and a coarse one stretches to fill. Days adds weekend shading and a full calendar picker.@@ -114,6 +116,8 @@ Omit `color` on a phase and it follows the Adom brand ramp, which adapts between |---------|--------| | Gantt / Table | Switch lens. Pinned to the left of the header so it never moves | | Fit | Fit the whole plan into the pane, no horizontal scrolling |+| A- / A+ | Step the text size for both views. Click the percentage to reset |+| Hover "today" | The current date | | Click a table cell | Edit owner, start, deadline, status or notes in place | | Click a table header | Sort by that column (deadline ascending by default) | | Click bar or label | Select + open editor |
SKILL.md+1@@ -186,6 +186,7 @@ rather than a category. ## Features +- **Text size** A-minus/A+ in the header: seven steps from 85% to 150%, applies to the chart and the table, persists in `localStorage` as `prefs.fs`. Row and bar geometry scale with the type - **Fit** button beside the cell-size picker: picks the finest grain whose whole span fits the pane, so the plan is fully visible with no horizontal scrolling. Chart-only - **Gantt / Table toggle** pinned first in the header, left of every conditional control so its position never shifts. Two lenses over the same items: the chart answers "when", the table answers "whose, and by when". See The table view below - **Click legend** to filter phases, milestones, dependency arrows, or the today line
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-07 (0.8.6, user feedback)++**The header named one project three times.** Brand carried an app-name and a+second subtitle, then the subject carried the title, then a unit count. The brand+now carries the app identity only (icon plus app-name, capped at 20ch so another+data file's name cannot move anything), and the project's subtitle moved to sit+with its title, where a subtitle belongs. So: one title, one subtitle, one small+app identity. Dropped the brand's copy rather than the subject's because the+subject is where the project is actually named, and the brand should say what the+app is, not what the plan is. The brand tooltip now says "adom-gantt, <appName>";+the full title and subtitle live on the subject's tooltip.++The header was then too crowded for its own contents at 1280 and below, so it+sheds chrome instead of truncating the project's name: under 1460px the unit+count, the "starts" label and the button labels go (icons and tooltips stay),+under 1180px the subtitle goes too. The subtitle also absorbs all flex shrink+before the title gives up a single character. Verified no ellipsis at 1600, 1440,+1280 or 1100.++**Text scale.** A-minus / A-plus with a percentage readout that doubles as the+reset, seven steps from 85% to 150%, persisted and applied before the chart is+built. One CSS variable drives every text size plus the row, bar, axis and+diamond geometry that has to grow with it, so nothing is rebuilt: the browser+reflows and the JS re-measures. Applies to both lenses, because it is a statement+about the reader, not about the chart. The control itself does not scale, and the+readout has a fixed width, so pressing it never moves it.++Two things had to be fixed to make it correct rather than merely bigger. The+dependency arrows put their endpoints at `offsetTop + 17`, which was half of a+34px row hardcoded, so every arrow was wrong the moment rows grew; they measure+the row now. And the re-measure after a scale change was silently skipped:+`scheduleRelayout` has an echo guard that ignores a resize reporting the same+pane width and height, which is exactly this case, so the milestone caps kept+their old row assignments and overlapped at 150%. Invalidating the guard before+scheduling fixes it, and it goes through the app's own debounced path rather than+a second bespoke one. Measured: zero cap overlaps at 85%, 100% and 150%, caps+still clear of the today tag, arrows on the row midpoint at 150%.++**Today has a tooltip.** Hovering the today tag says "Today" and the date, recomputed+on hover so a plan left open across midnight is not lying. The legend's Today entry+carries it too. The line itself stays `pointer-events: none` on purpose: a full-height+hit strip would sit over every bar it crosses and eat their clicks, which is a worse+trade than a tooltip, so the tag is the affordance and got a little more padding to+be a comfortable target.+ ## Build notes, 2026-08-07 (0.8.5, two UI changes from the user) **Fit.** A button next to the cell-size picker that fits the whole plan into the
gantt.html+175−14@@ -84,12 +84,18 @@ header .brand .app-name { color: var(--accent); } /* Bounded on purpose. The subtitle is author text of any length, and unbounded it pushed everything after it across the header (and off a narrow one). The full text is still the header's tooltip, and the project title sits right after it. */-header .brand .app-subtitle { color: var(--text2); font-weight: 500; max-width: 34ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+header .brand .app-name { max-width: 20ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } header .brand .sep { color: var(--text3); font-weight: 400; } header .vsep { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; }-header .subject { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; cursor: default; }-header .subject-name { color: var(--text); font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }-header .subject-meta { display: flex; align-items: center; gap: 6px; color: var(--text2); font-family: 'JetBrains Mono', monospace; font-size: 11px; white-space: nowrap; }+header .subject { display: flex; align-items: baseline; gap: 8px; min-width: 0; flex: 1; cursor: default; overflow: hidden; }+/* The one subtitle. It gives up width before the title does, so on a narrow header+ the thing being named survives and its description is what shortens. */+header .subject-sub { color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 12px; font-weight: 400; min-width: 0; flex-shrink: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+/* Does not shrink: the subtitle absorbs every bit of squeeze first, so the thing+ being named is the last text in the header to give up a character. max-width+ keeps it inside the box in the extreme case instead of shoving .actions along. */+header .subject-name { color: var(--text); font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; max-width: 100%; }+header .subject-meta { display: flex; align-items: center; gap: 6px; color: var(--text2); font-family: 'JetBrains Mono', monospace; font-size: 11px; white-space: nowrap; flex-shrink: 0; } header .subject-meta .sep { color: var(--text3); } header .start-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; font-family: 'Satoshi', sans-serif; } header .start-ctl label { color: var(--text2); font-size: 11px; }@@ -129,6 +135,32 @@ header .actions { display: flex; align-items: center; gap: 4px; margin-left: aut header .act-btn { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 10px; cursor: pointer; transition: background 0.15s, color 0.15s; display: flex; align-items: center; gap: 4px; } header .act-btn:hover { background: rgba(255,255,255,0.04); color: var(--text); } header .act-btn svg { width: 14px; height: 14px; }+/* Their labels change length ("Lines"/"Diamonds", "Light"/"Dark"). .actions is+ right-aligned, so a width change there drags everything left of it, including+ the Fit button. Pin them to their widest label. */+header #btnMsStyle { min-width: 96px; justify-content: center; }+header #btnTheme { min-width: 56px; justify-content: center; }+/* Below this the header is out of room and the project's own name starts to+ truncate, which is the one thing in here that must not. Shed chrome instead:+ the buttons keep their icons and their tooltips, the unit count goes (the axis+ already shows the span), and the "starts" label goes (its button says a date).+ With the labels gone the buttons are all one width, so nothing shifts. */+@media (max-width: 1460px) {+ header .subject-meta,+ header .start-ctl label,+ header .act-btn .btn-label,+ header #msStyleLabel { display: none; }+ /* Icon-only, but still pinned: the milestone glyph swaps between a bar and a+ diamond, which are not the same width, and .actions is right-aligned so that+ alone dragged the Fit button sideways. Theme keeps its word rather than+ becoming a blank button. */+ header #btnMsStyle { min-width: 36px; }+}+/* Narrower still: an abbreviated subtitle is worse than no subtitle. Drop it and+ keep the title whole. The full text stays on the header's tooltip. */+@media (max-width: 1180px) {+ header .subject-sub { display: none; }+} /* ── Legend ── */ .legend { display: flex; gap: 4px; padding: 8px 16px; border-bottom: 1px solid var(--border-muted); background: var(--surface); align-items: center; flex-wrap: wrap; flex-shrink: 0; }@@ -206,7 +238,12 @@ body.col-resizing { cursor: col-resize; user-select: 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-tag { position: absolute; top: 4px; transform: translateX(-50%); font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--today); background: var(--bg); padding: 1px 4px; border-radius: 3px; border: 1px solid rgba(0,184,176,0.25); pointer-events: none; z-index: 9; white-space: nowrap; }+/* 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: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; } .dep-svg polygon { fill: var(--accent); opacity: 0.5; transition: opacity 0.15s; }@@ -354,6 +391,60 @@ table.data-table { border-collapse: separate; border-spacing: 0; width: 100%; mi .labels-col, .timeline-col { -webkit-user-select: none; user-select: none; } .app { display: flex; flex-direction: column; height: 100vh; } input[type="file"] { display: none; }++/* ── Text scale ──────────────────────────────────────────────────────────────+ One multiplier drives every size that carries text, and the row geometry that+ has to grow with it, so a bigger typeface does not overflow its row. Sizes stay+ in px and are simply multiplied, which leaves all the existing layout maths+ alone: the JS measures offsetHeight/offsetWidth rather than assuming 34.+ These rules come last on purpose, so they win over the originals above. */+:root {+ --fs: 1;+ --row-h: calc(34px * var(--fs));+ --group-h: calc(28px * var(--fs));+ --bar-h: calc(20px * var(--fs));+ --ms-d: calc(10px * var(--fs));+}+.labels-col { width: var(--label-w, calc(clamp(150px, 24vw, 272px) * var(--fs))); }+.label-row { height: var(--row-h); font-size: calc(12px * var(--fs)); }+.label-row.group { height: var(--group-h); font-size: calc(12px * var(--fs)); }+.label-row.ms { font-size: calc(11px * var(--fs)); }+.tag { font-size: calc(9px * var(--fs)); }+.bar-row { height: var(--row-h); }+.bar-row.group { height: var(--group-h); }+.bar { top: calc((var(--row-h) - var(--bar-h)) / 2); height: var(--bar-h); }+.bar-note, .bar-note-edit { font-size: calc(10px * var(--fs)); }+.q-row, .label-h-q { height: calc(22px * var(--fs)); }+.m-row, .label-h-m { height: calc(24px * var(--fs)); }+.q-cell, .m-cell { font-size: calc(10px * var(--fs)); }+.ms-diamond { width: var(--ms-d); height: var(--ms-d); top: calc((var(--row-h) - var(--ms-d)) / 2); }+.ms-line .ms-cap { font-size: calc(9px * var(--fs)); max-width: calc(132px * var(--fs)); }+.today-tag { font-size: calc(9px * var(--fs)); }+.legend-item { font-size: calc(11px * var(--fs)); }+.tip { font-size: calc(12px * var(--fs)); }+.tip .tip-title { font-size: calc(13px * var(--fs)); }+.tip .tip-dates { font-size: calc(10px * var(--fs)); }+.tip .tip-body { font-size: calc(12px * var(--fs)); }+table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * var(--fs)); }+.data-table thead th { font-size: calc(11px * var(--fs)); }+.data-table td.c-owner, .data-table td.c-date { font-size: calc(11px * var(--fs)); }+.data-table .cell-input { font-size: calc(11px * var(--fs)); }+.data-table .trunc { max-width: calc(340px * var(--fs)); }+.data-table td.c-notes .trunc { max-width: calc(300px * var(--fs)); }++/* The scale control itself never scales: it is chrome, and a control that grew+ as you pressed it would walk away from the pointer. */+.fs-ctl { display: flex; align-items: stretch; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; flex-shrink: 0; background: var(--bg); }+.fs-ctl button { background: none; border: none; color: var(--text2); cursor: pointer; padding: 0; transition: background 0.15s, color 0.15s; display: flex; align-items: center; justify-content: center; }+.fs-ctl button:hover { background: rgba(255,255,255,0.05); color: var(--text); }+.fs-ctl .fs-btn { width: 26px; }+.fs-ctl .fs-btn svg { width: 15px; height: 15px; }+.fs-ctl .fs-btn.fs-big svg { width: 19px; height: 19px; }+.fs-ctl button:disabled { color: var(--text3); cursor: default; background: none; }+/* Fixed width: the readout goes "85%" to "100%" and a reflow here would nudge+ everything downstream of it. */+.fs-ctl .fs-pct { min-width: 46px; font-family: 'JetBrains Mono', monospace; font-size: 10px; border-left: 1px solid var(--border); border-right: 1px solid var(--border); }+.fs-ctl .fs-pct.off-default { color: var(--accent); } </style> </head> <body>@@ -488,17 +579,25 @@ function savedPrefs(){ if (SNAPSHOT) return {}; try { return JSON.parse(localStorage.getItem(PREF_KEY)) || {}; } catch { return {}; } }+// Seven steps, 85% to 150%. Discrete so the readout is a round number and the+// step buttons have somewhere definite to land.+const FS_STEPS = [0.85, 0.925, 1, 1.1, 1.2, 1.35, 1.5]; const prefs = Object.assign(- { theme: 'dark', msStyle: 'line', granularity: 'month', showToday: true, view: 'gantt' },+ { theme: 'dark', msStyle: 'line', granularity: 'month', showToday: true, view: 'gantt', fs: 1 }, viewDefaults(), savedPrefs() );+// A hand-edited or stale localStorage value must not put the app at 40%.+if (FS_STEPS.indexOf(prefs.fs) < 0) prefs.fs = 1; function savePrefs(){ try { localStorage.setItem(PREF_KEY, JSON.stringify(prefs)); } catch {} } function applyTheme(){ document.documentElement.setAttribute('data-theme', prefs.theme); } applyTheme();-// Set before the chart is built, so a plan that opens as a table never measures a-// hidden timeline. The toggle buttons do not exist yet on this first call.+// Both set before the chart is built: the view so a plan that opens as a table+// never measures a hidden timeline, the scale so the first paint is already at+// the size the user chose and nothing visibly resettles. The header buttons do+// not exist yet on either of these first calls. applyView();+applyFontScale(); // ── Date range ── // The timeline is derived from the data every time, so shifting the plan reshapes@@ -616,8 +715,8 @@ function addDays(ds, n){const[y,mo,d]=ds.split('-').map(Number);const dt=new Dat // ── Header with import/export buttons ── $('hdr').innerHTML = `- <div class="brand" title="${esc(project.subtitle||'')}"><svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="3" y="4" width="18" height="2" rx="1" opacity="0.5"/><rect x="5" y="9" width="12" height="2" rx="1"/><rect x="3" y="14" width="16" height="2" rx="1" opacity="0.7"/><rect x="7" y="19" width="10" height="2" rx="1" opacity="0.5"/></svg>- <span class="app-name">${esc(project.appName||'adom-gantt')}</span><span class="sep">·</span><span class="app-subtitle">${esc(project.subtitle||'Gantt chart')}</span></div>+ <div class="brand" title="adom-gantt${project.appName?', '+esc(project.appName):''}"><svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="3" y="4" width="18" height="2" rx="1" opacity="0.5"/><rect x="5" y="9" width="12" height="2" rx="1"/><rect x="3" y="14" width="16" height="2" rx="1" opacity="0.7"/><rect x="7" y="19" width="10" height="2" rx="1" opacity="0.5"/></svg>+ <span class="app-name">${esc(project.appName||'adom-gantt')}</span></div> <div class="vsep"></div> <!-- The lens switch is the primary control, so it sits first in the row, anchored to the brand block, which never changes width. It used to live inside .actions,@@ -629,8 +728,15 @@ $('hdr').innerHTML = ` <button class="vt-btn" data-view="table" role="tab"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9.5h18M3 15h18M10 9.5V20"/></svg>Table</button> </div>+ <div class="fs-ctl" id="fsCtl">+ <button class="fs-btn" id="fsDown" title="Smaller text"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><text x="12" y="17" text-anchor="middle" font-family="Familjen Grotesk,sans-serif" font-size="14" font-weight="600">A</text></svg></button>+ <button class="fs-pct" id="fsPct" title="Text size. Click to go back to 100%"></button>+ <button class="fs-btn fs-big" id="fsUp" title="Larger text"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><text x="12" y="19" text-anchor="middle" font-family="Familjen Grotesk,sans-serif" font-size="20" font-weight="600">A</text></svg></button>+ </div> <div class="vsep"></div>- <div class="subject"><span class="subject-name">${esc(project.title)}</span><span class="subject-meta"><span class="sep">·</span><span id="unitCount">${UNITS} months</span></span></div>+ <!-- One title, one subtitle. The brand used to repeat the project as an+ app-name plus a second subtitle, so a single plan was named three times. -->+ <div class="subject" title="${esc(project.title)}${project.subtitle?', '+esc(project.subtitle):''}"><span class="subject-name">${esc(project.title)}</span>${project.subtitle?`<span class="subject-sub">${esc(project.subtitle)}</span>`:''}<span class="subject-meta"><span class="sep">·</span><span id="unitCount">${UNITS} months</span></span></div> <div class="start-ctl" title="Project start. Changing it moves the whole plan."> <label>starts</label> <button class="date-btn" id="startBtn"></button>@@ -646,8 +752,8 @@ $('hdr').innerHTML = ` <button class="act-btn" id="btnMsStyle" title="Milestone style: diamond or dashed line"> <span id="msStyleIcon"></span><span id="msStyleLabel"></span></button> <button class="act-btn" id="btnTheme" title="Toggle light and dark"><span id="themeLabel"></span></button>- <button class="act-btn" id="btnImport"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg>Import</button>- <button class="act-btn" id="btnExport"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21V9m0 0l4 4m-4-4l-4 4M4 7V5a2 2 0 012-2h12a2 2 0 012 2v2"/></svg>Export</button>+ <button class="act-btn" id="btnImport" title="Import a plan: JSON, CSV, MS Project XML or Jira CSV"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg><span class="btn-label">Import</span></button>+ <button class="act-btn" id="btnExport" title="Export this plan: JSON, CSV, SVG or a self-contained HTML snapshot"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21V9m0 0l4 4m-4-4l-4 4M4 7V5a2 2 0 012-2h12a2 2 0 012 2v2"/></svg><span class="btn-label">Export</span></button> <button class="act-btn" id="btnHelp" style="font-family:'Familjen Grotesk',sans-serif;font-size:14px;font-weight:600;padding:4px 9px">?</button> </div>`; applyView(); // again, now that the toggle buttons exist@@ -925,6 +1031,8 @@ depL.addEventListener('click',()=>{depsVis=!depsVis;depL.classList.toggle('off', // separate chrome: the legend is already where this app hides and shows chart elements. const todayL=document.createElement('div');todayL.className='legend-item gantt-only'; todayL.innerHTML='<div class="legend-line"></div>Today';+refreshTodayTip(todayL);+todayL.addEventListener('mouseover',()=>refreshTodayTip(todayL)); todayL.addEventListener('click',()=>{ prefs.showToday=!prefs.showToday;savePrefs(); todayL.classList.toggle('off',!prefs.showToday);@@ -1034,6 +1142,8 @@ if(prefs.showToday===false)todayL.classList.add('off'); const today=new Date(); function todayX(){return toX(isoDay(today));}+// Uses the app's own tooltip, which renders through textContent nodes.+function refreshTodayTip(el){ if(!el)return; el.dataset.title='Today'; el.dataset.dates=fmt(isoDay(new Date())); } function positionToday(){ const tx=todayX(), on=prefs.showToday!==false && tx>0 && tx<W; if(todayLineEl){todayLineEl.style.left=tx+'px';todayLineEl.style.display=on?'':'none';}@@ -1041,6 +1151,11 @@ function positionToday(){ } {const tl=document.createElement('div');tl.className='today-line';bars.appendChild(tl);todayLineEl=tl; const tt=document.createElement('div');tt.className='today-tag';tt.textContent='today';bars.appendChild(tt);todayTagEl=tt;+ // Recomputed on hover, not at build: a plan left open overnight would otherwise+ // still say yesterday. The marker's own position is a separate question and+ // still comes from the load-time date, as it always has.+ refreshTodayTip(tt);+ tt.addEventListener('mouseover',()=>refreshTodayTip(tt)); positionToday();} timelineEl.appendChild(qRow);timelineEl.appendChild(mRow);timelineEl.appendChild(bars); const barTail=document.createElement('div');barTail.className='tailroom';timelineEl.appendChild(barTail);@@ -1164,11 +1279,15 @@ if(document.fonts&&document.fonts.ready)document.fonts.ready.then(()=>{if(prefs. 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();} // ── Dependencies ──+function sb0(entry){ return entry.row || entry.bar.closest('.bar-row'); } function drawDeps(){ depSvg.innerHTML='';let maxY=0; items.forEach(r=>{if(!r.deps||!r.id)return;const tgt=barEls[r.id];if(!tgt)return; r.deps.forEach(depId=>{const src=barEls[depId];if(!src)return;if(r._bDiv.classList.contains('dim')||items[src.idx]._bDiv.classList.contains('dim'))return;- const sb=src.bar,tb=tgt.bar,x1=sb.offsetLeft+sb.offsetWidth,y1=sb.closest('.bar-row').offsetTop+17,x2=tb.offsetLeft,y2=tb.closest('.bar-row').offsetTop+17,mx=x1+(x2-x1)*0.5;+ // Measure the row rather than assuming 17, which was half of a 34px row and+ // 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,mx=x1+(x2-x1)*0.5; const p=document.createElementNS('http://www.w3.org/2000/svg','path');p.setAttribute('d',`M${x1},${y1} C${mx},${y1} ${mx},${y2} ${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); maxY=Math.max(maxY,y1,y2);});});@@ -1736,6 +1855,48 @@ $('grainSel').addEventListener('change',function(){ rebuildTimeline(); }); +// ── Text scale ──+//+// Applies to both lenses, because it is a statement about the reader and not+// about the chart. The multiplier is a CSS variable, so nothing has to be+// rebuilt: the browser reflows and the JS re-measures. Everything downstream+// that was measured in pixels (milestone cap collision rows, the tailroom, the+// dependency arrows) has to be re-run after a change, which is what relayout does.+function applyFontScale(){ document.documentElement.style.setProperty('--fs', String(prefs.fs)); }+function syncFsCtl(){+ const i = FS_STEPS.indexOf(prefs.fs);+ const pct = $('fsPct');+ if(!pct) return;+ pct.textContent = Math.round(prefs.fs*100)+'%';+ pct.classList.toggle('off-default', prefs.fs !== 1);+ $('fsDown').disabled = i <= 0;+ $('fsUp').disabled = i >= FS_STEPS.length-1;+}+function setFontScale(v){+ if(FS_STEPS.indexOf(v)<0 || v===prefs.fs) return;+ prefs.fs = v; savePrefs(); applyFontScale(); syncFsCtl();+ renderTable();+ // Everything measured in pixels has to be measured again: the milestone cap+ // collision rows (at 150% the tags are wider and taller and need more rows+ // than they did at 100%), the tailroom, the dependency arrows.+ //+ // Go through the app's own debounced relayout rather than calling relayout()+ // directly, but first invalidate its echo guard. That guard skips a relayout+ // when the pane reports the same width and height as last time, which is+ // exactly what happens here: the text grew, the pane did not. Without this the+ // caps kept their old row assignments and overlapped each other at 150%.+ lastPaneW = lastPaneH = lastRelayoutW = -1;+ scheduleRelayout();+}+function stepFontScale(d){+ const i = FS_STEPS.indexOf(prefs.fs);+ setFontScale(FS_STEPS[Math.max(0, Math.min(FS_STEPS.length-1, i+d))]);+}+$('fsDown').addEventListener('click',()=>stepFontScale(-1));+$('fsUp').addEventListener('click',()=>stepFontScale(1));+$('fsPct').addEventListener('click',()=>setFontScale(1));+syncFsCtl();+ // ── Fit the plan to the pane ── // // Zoom is not continuous here: each grain carries a minimum cell width sized to
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.8.5",+ "version": "0.8.6", "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+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.8.5",+ "version": "0.8.6", "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": {