← Commit history

0.8.5: a Fit-to-width button, and the view toggle stops moving

Drew Owens ·35734d9bd3 · ·parent a7a9e78
Fit sits next to the cell-size picker and fits the whole plan into the pane in one click. Zoom here is not continuous: each grain carries a minimum cell width sized to the widest label it draws, and below that the timeline scrolls rather than squashing, so fitting to the pane means choosing a grain, not a scale. It walks finest to coarsest and takes the first whose full span clears its own minimum in the width actually available, which is the most detail the pane can hold without scrolling sideways; fitCOL then stretches the cells to fill it exactly. computeRange is refactored into a pure rangeFor(grain) so Fit can ask how wide a grain would be without switching to it first. It always rebuilds even when the grain does not change, because the gesture means use the width I have now. Chart-only, so it hides with the rest of the chart chrome in table view. The Gantt/Table toggle lived inside .actions, which is right-aligned, so every chart-only button that hid in table view made that group narrower and shoved the toggle sideways: it moved between clicks. It now sits first in the controls row, anchored to the brand, with every conditional control downstream of it. It also got icons, more padding, and a third active-state difference (weight, on top of fill and text colour) plus aria-selected. Moving it exposed one more thing: the brand subtitle was unbounded author text, and this plan's is a full sentence, so it alone pushed the toggle to the middle of a 1600px header and would have run a narrow one off the edge. Capped at 34ch with an ellipsis, full text on the header tooltip. Measured: the toggle's rect is identical across six control-state changes and at 1600, 1280 and 1100px window widths.
6 files changed +116−28
README.md+4−1
@@ -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. +**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.  **Editable milestones.** Drag a milestone sideways to move its date, or click it to edit its name, date and description. Line style is the default: the tag on the dashed rule is the handle and the milestone's own row is hidden, since the rule already spans the chart. Long titles wrap, and a tag that would still collide with its neighbour drops to a second row. Diamond style keeps the row.@@ -110,7 +112,8 @@ Omit `color` on a phase and it follows the Adom brand ramp, which adapts between  | Gesture | Action | |---------|--------|-| Gantt / Table | Switch lens |+| 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 | | 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+2−1
@@ -186,7 +186,8 @@ rather than a category.  ## Features -- **Gantt / Table toggle** in the header. Two lenses over the same items: the chart answers "when", the table answers "whose, and by when". See The table view below+- **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 - **Click a bar** to open the date editor with start/end pickers and dependency management - **Drag bar edges** to resize (changes start or end date)
docs/BUILD-NOTES.md+28
@@ -3,6 +3,34 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## 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+pane in one click. Zoom here is not continuous: each grain carries a minimum cell+width sized to the widest label it draws, and below that the timeline scrolls+rather than squashing into unreadability. So fitting to the pane means choosing a+grain, not a scale. `bestFitGrain` walks finest to coarsest and takes the first+whose full span clears its own minimum in the width actually available, which is+the most detail the pane can hold with no sideways scrolling; `fitCOL` then+stretches the cells to fill it exactly. `computeRange` was refactored into a pure+`rangeFor(grain)` so Fit can ask how wide a grain would be without switching to it+first. It always rebuilds, even when the grain does not change, because the gesture+means "use the width I have now" and the pane may have been resized since.++**The view toggle moved and grew.** It lived inside `.actions`, which is+right-aligned, so every chart-only button that hid in table view made that group+narrower and shoved the toggle sideways: the user was chasing it between clicks. It+now sits first in the controls row, anchored to the brand block, with the chart-only+controls all downstream of it where they cannot move it. It also got icons, more+padding and a third active-state difference (weight, on top of fill and text colour).++One thing that fell out of moving it: the brand's subtitle was unbounded author+text, and the DC plan's is a full sentence, so it alone pushed the toggle to the+middle of a 1600px header and would have run a narrow one off the edge. Capped at+34ch with an ellipsis, full text on the header's tooltip. Toggle now sits at a+fixed 385px, measured identical at 1600, 1280 and 1100px window widths and across+six control-state changes.+ ## Build notes, 2026-08-07 (0.8.4)  One residual from the 0.8.3 dateless-row work. The viewer, `/export/json`,
gantt.html+80−24
@@ -81,7 +81,10 @@ header { display: flex; align-items: center; gap: 12px; padding: 0 16px; height: header .brand { display: flex; align-items: center; gap: 8px; color: var(--accent); font-size: 13px; font-weight: 600; cursor: default; flex-shrink: 0; } header .brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; } header .brand .app-name { color: var(--accent); }-header .brand .app-subtitle { color: var(--text2); font-weight: 500; }+/* 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 .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; }@@ -280,11 +283,17 @@ body.col-resizing { cursor: col-resize; user-select: none; } .sel-count { position: fixed; bottom: 16px; left: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--accent); z-index: 99998; opacity: 0; transition: opacity 0.15s; } .sel-count.show { opacity: 1; } -/* ── View toggle: the same plan as a chart or as a table ── */-.view-toggle { display: flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; flex-shrink: 0; }-.view-toggle .vt-btn { background: none; border: none; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 11px; cursor: pointer; transition: background 0.15s, color 0.15s; }-.view-toggle .vt-btn:hover { background: rgba(255,255,255,0.04); color: var(--text); }-.view-toggle .vt-btn.on { background: var(--accent); color: var(--on-accent); }+/* ── View toggle: the same plan as a chart or as a table ──+   The primary control in the header, so it is bigger and heavier than the action+   buttons. Its position is anchored to the brand block on its left, which never+   changes width, so nothing downstream appearing or disappearing can move it. */+.view-toggle { display: flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; flex-shrink: 0; background: var(--bg); }+.view-toggle .vt-btn { display: flex; align-items: center; gap: 6px; background: none; border: none; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 12px; line-height: 1; padding: 6px 13px; cursor: pointer; transition: background 0.15s, color 0.15s; white-space: nowrap; }+.view-toggle .vt-btn svg { width: 14px; height: 14px; flex-shrink: 0; }+.view-toggle .vt-btn:hover { background: rgba(255,255,255,0.05); color: var(--text); }+/* Active carries three differences, not one: fill, text colour and weight, so it+   still reads as selected without relying on colour alone. */+.view-toggle .vt-btn.on { background: var(--accent); color: var(--on-accent); font-weight: 600; }  /* ── Table view ──    A second lens over the same items. The chart answers "when"; the table answers@@ -522,20 +531,23 @@ const ORIGINAL_START = (()=>{ let m=null;  let START=new Date(), UNITS=0;   // UNITS = months, or days when granularity is 'day' function addD(d,n){const x=new Date(d.getFullYear(),d.getMonth(),d.getDate());x.setDate(x.getDate()+n);return x;}-function computeRange(){+// The axis a given grain would produce, without touching the live one. Fit needs+// to ask "how wide would Days be?" about a grain it has not switched to yet.+function rangeFor(g){   const {minD,maxD}=scanRange();-  const a=parseISO(minD), b=parseISO(maxD), g=G();-  if(g==='day'){        START=addD(a,-3);-                        UNITS=Math.max(14, dayDiff(START,b)+8); }-  else if(g==='week'){  const s0=new Date(a.getFullYear(),a.getMonth(),a.getDate()); s0.setDate(s0.getDate()-s0.getDay()-7);-                        START=s0; UNITS=Math.max(8, Math.ceil(dayDiff(START,b)/7)+2); }-  else if(g==='month'){ START=new Date(a.getFullYear(),a.getMonth(),1);-                        UNITS=Math.max(2, mDiff(START,b)+2); }-  else if(g==='quarter'){START=new Date(a.getFullYear(),Math.floor(a.getMonth()/3)*3,1);-                        UNITS=Math.max(2, Math.ceil((mDiff(START,b)+1)/3)+1); }-  else {                START=new Date(a.getFullYear(),0,1);-                        UNITS=Math.max(2, (b.getFullYear()-START.getFullYear())+2); }+  const a=parseISO(minD), b=parseISO(maxD);+  if(g==='day'){       const s=addD(a,-3);+                       return {start:s, units:Math.max(14, dayDiff(s,b)+8)}; }+  if(g==='week'){      const s=new Date(a.getFullYear(),a.getMonth(),a.getDate()); s.setDate(s.getDate()-s.getDay()-7);+                       return {start:s, units:Math.max(8, Math.ceil(dayDiff(s,b)/7)+2)}; }+  if(g==='month'){     const s=new Date(a.getFullYear(),a.getMonth(),1);+                       return {start:s, units:Math.max(2, mDiff(s,b)+2)}; }+  if(g==='quarter'){   const s=new Date(a.getFullYear(),Math.floor(a.getMonth()/3)*3,1);+                       return {start:s, units:Math.max(2, Math.ceil((mDiff(s,b)+1)/3)+1)}; }+  const s=new Date(a.getFullYear(),0,1);+  return {start:s, units:Math.max(2, (b.getFullYear()-s.getFullYear())+2)}; }+function computeRange(){ const r=rangeFor(G()); START=r.start; UNITS=r.units; } computeRange(); let W = UNITS*COL; @@ -604,9 +616,20 @@ 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"><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>+  <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">&middot;</span><span class="app-subtitle">${esc(project.subtitle||'Gantt chart')}</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,+       which is right-aligned: every chart-only button that hid in table view made+       that group narrower and shoved the toggle sideways between clicks. -->+  <div class="view-toggle" id="viewToggle" role="tablist" title="Two lenses over the same plan: the chart, or the owner and deadline table">+    <button class="vt-btn" data-view="gantt" role="tab">+      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="3" y="5" width="13" height="3" rx="1.5"/><rect x="7" y="10.5" width="14" height="3" rx="1.5"/><rect x="5" y="16" width="10" height="3" rx="1.5"/></svg>Gantt</button>+    <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="vsep"></div>   <div class="subject"><span class="subject-name">${esc(project.title)}</span><span class="subject-meta"><span class="sep">&middot;</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>@@ -616,12 +639,10 @@ $('hdr').innerHTML = `       <option value="month">Months</option><option value="quarter">Quarters</option>       <option value="year">Years</option>     </select>+    <button class="act-btn" id="btnFit" title="Fit the whole plan into the pane: the finest cell size that shows every date without scrolling sideways">+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 5v14M21 5v14"/><path d="M7 12h10"/><path d="M10 9l-3 3 3 3M14 9l3 3-3 3"/></svg>Fit</button>   </div>   <div class="actions">-    <div class="view-toggle" id="viewToggle" title="Two lenses over the same plan: the chart, or the owner and deadline table">-      <button class="vt-btn" data-view="gantt">Gantt</button>-      <button class="vt-btn" data-view="table">Table</button>-    </div>     <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>@@ -1715,6 +1736,37 @@ $('grainSel').addEventListener('change',function(){   rebuildTimeline(); }); +// ── Fit the plan to the pane ──+//+// Zoom is not continuous here: each grain carries a minimum cell width sized to+// the widest label it draws, and below that the timeline scrolls rather than+// squashing into unreadability. So fitting to the pane means choosing a grain,+// not a scale. Finest first, take the first whose whole span clears its own+// minimum in the width we actually have: that is the most detail the pane can+// hold without scrolling sideways. fitCOL() then stretches the cells to fill it+// exactly. If even Years cannot fit, Years is still the closest we can get.+const GRAIN_ORDER = ['day','week','month','quarter','year'];+function paneWidth(){ return (timelineEl.clientWidth || (window.innerWidth - 272)) - 1; }+function bestFitGrain(){+  const avail = paneWidth();+  for(const g of GRAIN_ORDER){+    if(rangeFor(g).units * GRAINS[g].min <= avail) return g;+  }+  return 'year';+}+function fitToWidth(){+  const g = bestFitGrain();+  const changed = g !== G();+  prefs.granularity = g;+  savePrefs();+  $('grainSel').value = g;+  // Always rebuild, even when the grain is unchanged: the pane may have been+  // resized since, and this is the gesture that says "use the width I have now".+  rebuildTimeline();+  flash(saveInd, changed ? `fit to ${GRAINS[g].label.toLowerCase()}` : 'fit to width');+}+$('btnFit').addEventListener('click',fitToWidth);+ // ── Date picker ── const datePop=$('datePop'); let dpYear=0, dpMonth=0;@@ -2084,7 +2136,11 @@ function applyView(){   const v = (prefs.view==='table') ? 'table' : 'gantt';   document.documentElement.setAttribute('data-view',v);   const vt=$('viewToggle');-  if(vt)vt.querySelectorAll('.vt-btn').forEach(b=>b.classList.toggle('on',b.dataset.view===v));+  if(vt)vt.querySelectorAll('.vt-btn').forEach(b=>{+    const on=b.dataset.view===v;+    b.classList.toggle('on',on);+    b.setAttribute('aria-selected',on?'true':'false');+  }); } function setView(v){   if(v!=='table'&&v!=='gantt')return;
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.8.4",+  "version": "0.8.5",   "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.4",+  "version": "0.8.5",   "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": {