← Commit history

0.18.2: the table is a fixed grid, not a content-driven one

Drew Owens ·cca4c727b3 · ·parent 874b239
Reported as 'columns in table view seem to resize depending on where I click', and they were. The table sat in table-layout auto until the user's first manual resize, and renderTable() replaces the whole tbody on a click, an edit, a sort, a filter or a new row, so auto layout re-solved every column from the body content each time. Measured across 35 interactions on 0.18.1, the header widths alternated between an equal split of 206.88px and the content-driven [429.84, 172.78, 108.25, 116.56, 116.56, 120.09, 402.8, 188.11, 30], flipping on 22 of the 35 steps. table-layout is fixed from the first paint now, always, so the browser sizes columns from the header row alone and nothing in the body can reach them. .fixed-cols no longer means 'use fixed layout'; it means 'the user has set widths of their own', which is the only thing it should ever have meant. The default sizing rule is about column TYPES rather than content: Name 23%, Notes 22%, Provenance 14%, Phase 10%, Owner 8%, Status 8%, Start 7.5%, Deadline 7.5%, delete 30px. Prose columns are wide, known-shape values are narrow, and the same nine numbers apply to every plan. Percentages so the grid fills its pane; the first drag freezes every column to pixels through the existing snapshotColWidths() and the table is pixel-driven from then on, which is the Excel bargain. scrollbar-gutter is reserved, because a filter that shortens the list used to take the vertical scrollbar away and grow every proportional column by a fraction. The .trunc caps (340px, 300px, and their font-scaled variants) are gone for max-width 100%, so ellipsis happens at the column the user has. The resize grip moved from right:-3px to right:0 and 8px wide, since cells now clip at the column in every mode and it was straddling the boundary. Verified as an invariant: 35 captures of all nine header widths across every editable column type opened and cancelled, 120 characters typed, hover, three sort states and a second column, owner and text filters, clearing them, creating a row with an enormous name and note, deleting it, undoing, and scrolling. All 35 byte-identical. The same battery at 150% text scale (identical, same widths), after a grip drag that moved Name 380.64 to 501 for a 120px gesture and persisted it (identical), after a double-click fit on Notes (persisted), and after a full page reload with those pixel widths in force (identical). Zero-shift editing re-verified, no body horizontal overflow, zero JS errors. The 52-key fingerprint's colWidths moves on purpose for the first time: [430, 173, 108, 117, 117, 120, 403, 188, 30] becomes [381, 166, 132, 124, 124, 132, 364, 232, 30]. That key IS the change; the rest is still the 0.15.1 baseline.
5 files changed +116−17
SKILL.md+1
@@ -207,6 +207,7 @@ rather than a category. - **Milestones are editable too**: drag one sideways to move its date, or click it for a panel with its name, date and description. Line style is the default: the tag on the dashed rule is the handle, and the milestone's grid row is hidden since the rule already carries it. Diamond style keeps the row. Long titles wrap, and any tag that would still collide with its neighbour drops to a second row, so tags never overlap - **Dynamic today marker** shows the current date, positioned with the same grain-aware mapping as the bars, so it is correct at every cell size - **Resizable name column**: drag the divider, or double-click it to fit the longest name. Width persists in `localStorage`+- **Table columns are a fixed grid.** Default widths are per column type and identical for every plan (Name 23%, Notes 22%, Provenance 14%, Phase 10%, Owner 8%, Status 8%, Start 7.5%, Deadline 7.5%, delete 30px). Nothing in the body can change them: clicking a cell, opening or cancelling an editor, hovering, sorting, filtering, adding or deleting a row all leave every column exactly where it was. Widths change only when the user drags a header grip or double-clicks one to fit the column to its content, and those persist in `prefs.tableCols`, after which the table is pixel-driven - **Light mode** for printing (dark is the default; printing always uses the light theme) - **Scroll past the last row** so the bottom of the plan can sit centred in the pane 
docs/BUILD-NOTES.md+75−1
@@ -3,6 +3,80 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-10 (0.18.2, the table is a grid again)++User report: "columns in table view seem to resize depending on where I click."+They were, and the measurement is unambiguous. The table sat in `table-layout: auto`+until the user's first manual resize, and `renderTable()` replaces the whole tbody+on a click, an edit, a sort, a filter or a new row. Auto layout re-solves every+column from the body content on each of those, so the widths were being recomputed+constantly: captured across 35 interactions on 0.18.1, the header widths alternated+between an equal split of `206.88px` and the content-driven+`[429.84, 172.78, 108.25, 116.56, 116.56, 120.09, 402.8, 188.11, 30]`, flipping on+22 of the 35 steps. That is a visible jump on almost every click.++I had seen this exact alternation while verifying 0.16.0 and wrote it off as a+headless measurement artifact because it reproduced identically on the pre-refactor+build. It reproduced identically because it was a real bug that predated the+refactor. Worth remembering: "the same on both builds" only rules out a regression,+it does not rule out a defect.++**`table-layout: fixed` from the first paint, always.** Under fixed layout the+browser sizes columns from the header row alone and nothing in the body can reach+them. `.fixed-cols` no longer means "use fixed layout"; it now means "the user has+set widths of their own", which is the only thing it should ever have meant.++**The default sizing rule is about column TYPES, not about content.** Name 23%,+Notes 22%, Provenance 14%, Phase 10%, Owner 8%, Status 8%, Start 7.5%, Deadline+7.5%, delete 30px. A name and a note are prose so they are wide; dates, owner and+status have values of known shape so they are narrow; the delete column is an icon.+The same nine numbers for every plan and every data set, so the grid a person+learns is the grid they get next time. Percentages rather than pixels so it fills+the pane it is given; the first drag calls the existing `snapshotColWidths()`,+which freezes every column to its current pixel width, and the table is+pixel-driven from then on. That is the Excel bargain: before you touch anything the+columns are what the sheet says, and the moment you drag one they are numbers you+own, in `prefs.tableCols`, exactly as 0.15.0 already stored them.++**The scrollbar gutter is reserved.** With the layout fixed, 33 of 35 steps went+identical and the two that did not were the owner filter and the text filter. Both+shorten the list enough to take the vertical scrollbar away, which makes the pane+15px wider, which grows every proportional column by a fraction. A width change+with no cause the reader can see is the same complaint in a smaller size, so+`scrollbar-gutter: stable` reserves it whether or not there is anything to scroll.++**Two smaller things fell out.** The `.trunc` spans dropped their 340px and 300px+caps for `max-width: 100%` in every mode, so ellipsis happens at the column the+user has rather than at a number from the auto-layout era, and the+font-scaled variants of those caps went with them. And the resize grip moved from+`right: -3px` to `right: 0`, because cells now clip at the column in every mode and+the grip was straddling the boundary with 3px of itself outside the header; it is+8px fully inside now, so it is a bigger target rather than a clipped one.++**Verified as an invariant, not as a spot check.** Thirty-five captures of all nine+header widths across: opening and cancelling an editor in all eight editable column+types, typing 120 characters into a notes editor, hovering and unhovering a row,+three sort states plus a second column plus back to plan order, an owner filter, a+text filter, clearing filters, creating a row with a deliberately enormous name,+owner and note, deleting it, undoing the delete, and scrolling the pane and back.+**All 35 byte-identical.** Then the same battery at 150% text scale (30 captures,+identical, and at the same widths, because pane-relative percentages do not move+when the type does, which is the right answer: a column layout should not jump+because someone changed the text size). Then after a real grip drag, which moved+Name from 380.64 to 501 for a 120px gesture and persisted 501 to `prefs.tableCols`:+30 more captures, identical. Then a double-click on the Notes grip, which fitted it+to content and clamped at the existing 720px maximum, persisted. Then a full page+reload in the same browser profile: the persisted pixel widths came back exactly,+and 30 more captures, identical. Zero-shift editing re-verified on top of all of+it. No horizontal overflow on the body, the table scrolling inside its own+container, and zero JS errors throughout.++**The fingerprint moved on purpose, for the first time in five releases.** Its+`colWidths` key goes from the content-driven `[430, 173, 108, 117, 117, 120, 403,+188, 30]` to the type-based `[381, 166, 132, 124, 124, 132, 364, 232, 30]`. That+key is the change. Everything else in the 52 is still the 0.15.1 baseline apart+from the version string.+ ## Build notes, 2026-08-10 (0.18.1, the New panel remembers the last one)  Adding six tasks for the same person in the same week meant retyping the owner and@@ -31,7 +105,7 @@ from there. That needed no special case, and it is what the panel looks like it doing: the field you filled in is the field that is remembered.  A prefilled field with no explanation is a confusing field, so the owner label-carries "from your last new item" only while values are actually being carried.+carries "from your last new item" only while values are being carried.  Verified headless: a first open is pristine with today and today plus fourteen and no hint; creating Caleb / 2026-09-02 / 2026-09-25 and reopening prefills all three
gantt.html+37−13
@@ -387,8 +387,12 @@ header .subject-meta .app-ver { color: var(--text3); } .table-tools .tt-btn:hover { background: rgba(255,255,255,0.04); color: var(--text); } .table-count { margin-left: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); } /* The table scrolls inside its own box, so a wide plan never pushes the page sideways. */-.table-scroll { flex: 1; min-height: 0; overflow: auto; }-table.data-table { border-collapse: separate; border-spacing: 0; width: 100%; min-width: 900px; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text-body); }+.table-scroll { flex: 1; min-height: 0; overflow: auto; scrollbar-gutter: stable; }+/* The gutter is reserved whether or not there is anything to scroll. Without it a+   filter that shortens the list takes the vertical scrollbar away, the pane gets+   15px wider, and every proportional column grows by a fraction: a width change+   with no cause the reader can see, which is the same complaint in a smaller size. */+table.data-table { table-layout: fixed; border-collapse: separate; border-spacing: 0; width: 100%; min-width: 900px; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text-body); } .data-table thead th { position: sticky; top: 0; z-index: 2; background: var(--surface); border-bottom: 1px solid var(--border); font-family: 'Familjen Grotesk', sans-serif; font-size: 11px; font-weight: 600; color: var(--text2); text-align: left; padding: 8px 10px; white-space: nowrap; user-select: none; } .data-table thead th.sortable { cursor: pointer; } .data-table thead th.sortable:hover { color: var(--text); }@@ -404,8 +408,8 @@ table.data-table { border-collapse: separate; border-spacing: 0; width: 100%; mi .data-table td.editable { cursor: text; } .data-table td.editable:hover { box-shadow: inset 0 0 0 1px var(--border); border-radius: 4px; } .data-table td.empty { color: var(--text2); }-.data-table .trunc { display: block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }-.data-table td.c-notes .trunc { max-width: 300px; color: var(--text-body); }+.data-table .trunc { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.data-table td.c-notes .trunc { color: var(--text-body); } .data-table td.c-notes.empty .trunc { color: var(--text2); } .data-table .phase-dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 7px; vertical-align: middle; } /* The editor is laid OVER the cell, not put inside it. Replacing the cell's@@ -492,14 +496,16 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 .label-row .row-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-right: 22px; } /* ---- resizable table columns ---- */ .data-table th { position: relative; }-.col-grip { position: absolute; top: 0; right: -3px; width: 7px; height: 100%; cursor: col-resize; z-index: 3; }+.col-grip { position: absolute; top: 0; right: 0; width: 8px; height: 100%; cursor: col-resize; z-index: 3; } .col-grip::after { content: ''; position: absolute; inset: 4px 3px; background: transparent; transition: background 0.15s; } .col-grip:hover::after, .col-grip.dragging::after { background: var(--accent); } body.col-sizing { cursor: col-resize; user-select: none; }-table.data-table.fixed-cols { table-layout: fixed; }-/* Under user-set widths the column decides the truncation, not a fixed px cap. */-.data-table.fixed-cols .trunc { max-width: 100%; }-.data-table.fixed-cols td, .data-table.fixed-cols th { overflow: hidden; }+/* The grid is fixed from the first paint, so every cell clips at its column and+   nothing in the body can reach a width. .fixed-cols now only means "the user has+   set widths of their own", which is what switches the table from the proportional+   defaults to pixels. */+.data-table td, .data-table th { overflow: hidden; }+.data-table thead th { text-overflow: ellipsis; }  .drop-indicator { position: absolute; left: 0; right: 0; height: 2px; background: var(--accent); z-index: 20; pointer-events: none; border-radius: 1px; box-shadow: 0 0 6px rgba(0,184,176,0.4); display: none; } .drop-indicator.show { display: block; }@@ -547,8 +553,6 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va .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. */@@ -3439,16 +3443,36 @@ document.addEventListener('keydown',e=>{if(e.key==='Escape'&&laneDrag)endLaneDra const COL_MIN=56; function colKeys(){ return [...tableHead.querySelectorAll('th')].map(th=>th.dataset.sort||'del'); } function fixedCols(){ return prefs.tableCols&&Object.keys(prefs.tableCols).length>0; }+// The default sizing rule, and it is a rule about COLUMN TYPES rather than about+// content: a name and a note are wide because they are prose, dates and status are+// narrow because their values have a known shape, and the delete column is an icon.+// The same nine numbers for every plan and every data set.+//+// This replaces a table that was in `table-layout: auto` until the user's first+// resize, which is what "columns resize depending on where I click" was. Auto+// layout re-solves every column from the body content, and renderTable() replaces+// the whole tbody on a click, an edit, a sort, a filter or a new row, so the widths+// were being recomputed constantly and visibly. Under `table-layout: fixed` the+// browser sizes columns from the header row alone and nothing in the body can+// reach them, which is the Excel model: the grid is a grid, and only you move it.+//+// Percentages rather than pixels so the grid fills the pane it is given. The first+// drag calls snapshotColWidths(), which freezes every column to its current pixel+// width, and the table is pixel-driven from then on. That is also what Excel does:+// before you touch anything the columns are whatever the sheet says, and the moment+// you drag one they are numbers you own.+const COL_DEFAULTS={name:'23%',phase:'10%',owner:'8%',start:'7.5%',deadline:'7.5%',status:'8%',notes:'22%',prov:'14%',del:'30px'}; function applyColWidths(){   const table=tableHead.closest('table');   if(!table)return;   const on=fixedCols();+  // The class now means "the user has set widths", not "use fixed layout": the+  // layout is fixed either way.   table.classList.toggle('fixed-cols',on);-  if(!on){ tableHead.querySelectorAll('th').forEach(th=>{th.style.width='';}); return; }   const w=prefs.tableCols||{};   tableHead.querySelectorAll('th').forEach(th=>{     const k=th.dataset.sort||'del';-    if(w[k])th.style.width=w[k]+'px';+    th.style.width=(on&&w[k])?(w[k]+'px'):(COL_DEFAULTS[k]||'');   }); } function snapshotColWidths(){
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.18.1",+  "version": "0.18.2",   "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.18.1",+  "version": "0.18.2",   "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+}