oficina / sheets Public

Fit selected columns to displayed content and persist group resizing

Paul Campbell pushed by paul@keymail.dev 95ba60076977e783a47aab8284e16370764e65a9 parent 3786dd6
6 files changed, +216 −17
  • docs/superpowers/specs/2026-09-16-browser-menus-autofit.md +41 −0
  • internal/sheets/static/autofit.js +76 −0
  • internal/sheets/static/commands.js +1 −0
  • internal/sheets/static/sizes.js +23 −17
  • internal/sheets/templates/sheet.html +2 −0
  • tools/drive/autofit.mjs +73 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-09-16-browser-menus-autofit.md b/docs/superpowers/specs/2026-09-16-browser-menus-autofit.md
new file mode 100644
index 0000000..3493668
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-16-browser-menus-autofit.md
@@ -0,0 +1,41 @@
+# Browser menus and column auto-fit
+
+Requested by Paul, 2026-09-16: fix File → New sheet, audit other inert menu
+items, and support fitting one or several columns by double-clicking a divider.
+
+The New sheet form and server route were already wired. Reproduced with actual
+server-rendered markup/assets: Chromium submitted it, WebKit did not. WebKit
+blurs a menu summary without focusing the clicked button; the focusout handler
+closed the menu before click could activate the button. Keep that menu open
+when the pointer is activating an item inside it. Keyboard departures, Escape,
+and outside clicks continue to close it.
+
+The audit also found grid keyboard handling swallowing native menu activation,
+and menu arrow handling intercepting number-field stepping. Menu keys remain
+inside the menu; resize fields retain their native editing/submission behavior.
+
+Column auto-fit measures displayed content with each cell's rendered font,
+letter spacing, padding, and borders. It includes loaded rows below the viewport
+and header labels. A double-click on the existing right-edge resize handle fits
+that column, or each column in the current selection when it includes that
+handle. Format → Auto-fit columns provides the keyboard/menu equivalent.
+Widths are bounded by the existing 16–2000px server limits. Truncated sheets
+report that auto-fit measures only loaded grid contents. A failed write stops
+the remaining columns. Multi-column manual resizing now persists every column,
+with ordered requests rather than racing full-size-table responses.
+
+Validation:
+
+- `TestBrowserMenuFixture` exports a synthetic server-rendered page and its real
+ linked assets, without session cookies, for browser component checks.
+- `tools/drive/menu-actions.mjs` activates every menu link, form, command and
+ formatting choice in Chromium and WebKit against mocked transport. It also
+ checks keyboard submission, resize stepping, Escape and outside clicks.
+- `tools/drive/autofit.mjs` exercises fresh-grid double-click, selected headers,
+ offscreen formatted values, independent widths, persisted widths on reload,
+ manual group resizing, formula display values, bounds and failed-write retry.
+
+Source review: GPT-6 Astra, 2026-09-16; no remaining blocking findings after
+fixing fresh-grid divider fitting and matching the vendor's resize hit area.
+Deployed-canary review remains outstanding; these checks are component tests,
+not deployed-server approval.
diff --git a/internal/sheets/static/autofit.js b/internal/sheets/static/autofit.js
new file mode 100644
index 0000000..add3598
--- /dev/null
+++ b/internal/sheets/static/autofit.js
@@ -0,0 +1,76 @@
+/* Fit columns to displayed values, using each cell's actual font and padding.
+ * Raw formulas are deliberately not measured: their displayed results are. */
+(function () {
+ 'use strict';
+ var host = document.querySelector('[data-sheet-grid]');
+ if (!host) return;
+ var canvas = document.createElement('canvas');
+ var context = canvas.getContext('2d');
+ var fitting = false;
+
+ function width(el) {
+ if (!el || !el.textContent) return 0;
+ var style = getComputedStyle(el);
+ context.font = style.font || [style.fontStyle, style.fontWeight, style.fontSize, style.fontFamily].join(' ');
+ var spacing = parseFloat(style.letterSpacing) || 0;
+ var widest = 0;
+ el.textContent.split(/\r?\n/).forEach(function (line) {
+ widest = Math.max(widest, context.measureText(line).width + Math.max(0, line.length - 1) * spacing);
+ });
+ return widest + (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.paddingRight) || 0) +
+ (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.borderRightWidth) || 0) + 4;
+ }
+
+ window.SheetsAutoFit = async function (column) {
+ var g = window.SheetsGrid;
+ if (!g || !g.mayWrite || fitting || !context) return;
+ var ws = g.worksheet(), s = g.selection();
+ if (!ws || (!s && typeof column !== 'number')) return;
+ var first = s ? Math.min(s.c, s.c2) : column;
+ var last = s ? Math.max(s.c, s.c2) : column;
+ // A divider outside the selection fits just its own column.
+ if (typeof column === 'number' && (column < first || column > last)) first = last = column;
+ fitting = true;
+ try {
+ if (document.fonts) await document.fonts.ready;
+ if (window.SheetsSizes) await window.SheetsSizes.settled();
+ for (var c = first; c <= last; c++) {
+ var size = Math.max(16, width(ws.headers[c]));
+ for (var r = 0; r < g.rows; r++) {
+ var td = ws.records[r] && ws.records[r][c] && ws.records[r][c].element;
+ size = Math.max(size, width(td));
+ }
+ // The server's existing width bounds also govern auto-fit.
+ if (!await g.post('/size', { col: c, size: Math.min(2000, Math.ceil(size)) })) break;
+ }
+ var surface = host.closest('[data-sheet-surface]');
+ if (surface.dataset.windowTruncated && window.SheetsNote) {
+ window.SheetsNote.say('Columns fitted to the loaded cells; this sheet extends beyond the grid window.', 'warn');
+ }
+ } finally { fitting = false; }
+ };
+
+ // The vendor treats even an unmoved resize click as a group resize. Suppress
+ // that no-op so the two clicks before auto-fit don't first equalize widths.
+ document.addEventListener('mouseup', function () {
+ var g = window.SheetsGrid, ws = g && g.worksheet();
+ var resize = ws && ws.resizing;
+ if (!resize || resize.column === undefined) return;
+ var col = ws.cols[resize.column];
+ if (!col || Number(col.colElement.getAttribute('width')) !== Number(resize.width)) return;
+ host.querySelectorAll('.resizing').forEach(function (el) { el.classList.remove('resizing'); });
+ ws.resizing = null;
+ }, true);
+
+ host.addEventListener('dblclick', function (e) {
+ var td = e.target.closest('thead td[data-x]');
+ if (!td) return;
+ var rect = td.getBoundingClientRect();
+ var col = Number(td.dataset.x);
+ // Match the vendor's resize handle so mousedown preserves the selection.
+ if (rect.right - e.clientX > 6) return;
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ window.SheetsAutoFit(col);
+ }, true);
+}());
diff --git a/internal/sheets/static/commands.js b/internal/sheets/static/commands.js
index 6dfb90a..1177c5b 100644
--- a/internal/sheets/static/commands.js
+++ b/internal/sheets/static/commands.js
@@ -76,6 +76,7 @@
'fill-down': function () { fillFrom(1, 0); },
'fill-right': function () { fillFrom(0, 1); },
+ 'autofit-columns': function () { if (window.SheetsAutoFit) window.SheetsAutoFit(); },
'select-all': function () {
var ws = g.worksheet();
if (ws && ws.updateSelectionFromCoords) {
diff --git a/internal/sheets/static/sizes.js b/internal/sheets/static/sizes.js
index 9c71226..c2af959 100644
--- a/internal/sheets/static/sizes.js
+++ b/internal/sheets/static/sizes.js
@@ -20,6 +20,7 @@
* back — two tabs open, and one person's drag becomes an endless
* round trip between them. */
var applying = false;
+ var pending = Promise.resolve();
function apply(ws, sizes) {
if (!ws || !sizes) return;
@@ -50,7 +51,10 @@
if (!ctx.mayWrite) return opts;
opts.onresizecolumn = function (instance, col, width) {
- post(ctx, { col: parseInt(col, 10), size: Math.round(width) });
+ // A multi-column drag reports an array; persist every selected column.
+ (Array.isArray(col) ? col : [col]).forEach(function (index) {
+ post(ctx, { col: parseInt(index, 10), size: Math.round(width) });
+ });
};
opts.onresizerow = function (instance, row, height) {
post(ctx, { row: parseInt(row, 10), size: Math.round(height) });
@@ -62,22 +66,24 @@
if (applying) return;
if (!isFinite(body.size) || body.size <= 0) return;
body.client = ctx.client;
- fetch(ctx.base + '/size', {
- method: 'POST',
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body)
- }).then(function (resp) {
- /* A refused resize is not repainted from the response — the
- * column is already the width the person dragged it to, and
- * snapping it back under their cursor is worse than leaving a
- * width the server did not keep. The next reload is the
- * correction, and a refusal here means a bound was hit, which is
- * visible in the drag itself. */
- if (!resp.ok) return;
- return resp.json().then(function (payload) { ctx.paint(payload); });
- }).catch(function () { /* the stream's own reconnect will resync */ });
+ pending = pending.then(function () {
+ return fetch(ctx.base + '/size', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ }).then(function (resp) {
+ /* A refused resize is not repainted from the response — the
+ * column is already the width the person dragged it to, and
+ * snapping it back under their cursor is worse than leaving a
+ * width the server did not keep. The next reload is the
+ * correction, and a refusal here means a bound was hit, which is
+ * visible in the drag itself. */
+ if (!resp.ok) return;
+ return resp.json().then(function (payload) { ctx.paint(payload); });
+ }).catch(function () { /* the stream's own reconnect will resync */ });
+ });
}
- window.SheetsSizes = { apply: apply, extend: extend };
+ window.SheetsSizes = { apply: apply, extend: extend, settled: function () { return pending; } };
}());
diff --git a/internal/sheets/templates/sheet.html b/internal/sheets/templates/sheet.html
index f617629..8de06d1 100644
--- a/internal/sheets/templates/sheet.html
+++ b/internal/sheets/templates/sheet.html
@@ -12,6 +12,7 @@
<script defer src="{{asset "static/live.js"}}"></script>
<script defer src="{{asset "static/paint.js"}}"></script>
<script defer src="{{asset "static/sizes.js"}}"></script>
+<script defer src="{{asset "static/autofit.js"}}"></script>
<script defer src="{{asset "static/grid.js"}}"></script>
<script defer src="{{asset "static/menubar.js"}}"></script>
<script defer src="{{asset "static/formulabar.js"}}"></script>
@@ -262,6 +263,7 @@
They post through commands.js because the size route takes JSON;
the scriptless path to a resize is named in the spec's §14 with
the rest of the table view's formatting debt. */}}
+<button type="button" data-cmd="autofit-columns">Auto-fit columns</button>
<form data-size-form data-axis="col">
<label for="colw">Column width</label>
<input rst-input id="colw" name="size" type="number" min="16" max="2000" step="1" placeholder="px">
diff --git a/tools/drive/autofit.mjs b/tools/drive/autofit.mjs
new file mode 100644
index 0000000..ec34534
--- /dev/null
+++ b/tools/drive/autofit.mjs
@@ -0,0 +1,73 @@
+// Uses TestBrowserMenuFixture's rendered page with synthetic cells and size API.
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+const pw=await import(process.env.PLAYWRIGHT||'playwright');
+const fixture=JSON.parse(fs.readFileSync(process.env.SHEETS_MENU_FIXTURE));
+for(const name of ['chromium','webkit']) {
+ const browser=await pw[name].launch(name==='webkit'&&process.env.WEBKIT_EXECUTABLE_PATH?{executablePath:process.env.WEBKIT_EXECUTABLE_PATH}:{});
+ try {
+ const page=await browser.newPage({viewport:{width:1400,height:900}});
+ let requests=[],sizes={cols:{}},reject=false;
+ const errors=[];page.on('pageerror',e=>errors.push(e.message));
+ const cells=[{r:0,c:0,raw:'=SUM(B1:B80)',display:'5'},
+ {r:80,c:0,raw:'The widest value is below the viewport',display:'The widest value is below the viewport',f:'bold'},
+ {r:1,c:1,raw:'Medium length',display:'Medium length'},
+ {r:0,c:2,raw:'=A_VERY_LONG_FORMULA_WITH_A_SHORT_RESULT()',display:'1'},
+ {r:0,c:3,raw:'W'.repeat(1000),display:'W'.repeat(1000)}];
+ await page.route('https://sheets.test/**',async route=>{
+ const req=route.request(),path=new URL(req.url()).pathname;
+ if(fixture.assets[path])return route.fulfill({body:fixture.assets[path],contentType:path.endsWith('.js')?'application/javascript':path.endsWith('.css')?'text/css':'image/svg+xml'});
+ if(path.endsWith('/stream'))return route.fulfill({body:'',contentType:'text/event-stream'});
+ if(path.endsWith('/size')&&req.method()==='POST') {
+ const value=req.postDataJSON();requests.push(value);
+ if(reject)return route.fulfill({status:400,body:'refused'});
+ sizes.cols[value.col]=value.size;
+ }
+ if(path.startsWith('/api/'))return route.fulfill({json:{version:1,cells,formats:{bold:{b:true,i:true}},sizes}});
+ return route.fulfill({body:fixture.html,contentType:'text/html'});
+ });
+ async function load() {
+ requests=[];sizes={cols:{}};reject=false;
+ await page.goto('https://sheets.test/sheets/1');await page.waitForFunction(()=>SheetsGrid?.worksheet());
+ await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
+ }
+ const header=c=>page.locator(`.jss_worksheet > thead > tr:first-child > td[data-x="${c}"]`);
+ async function divider(c) {
+ const rect=await header(c).boundingBox();
+ await page.mouse.dblclick(rect.x+rect.width-2,rect.y+rect.height/2);
+ }
+ async function resized(c) {
+ await page.waitForFunction(c=>Number(SheetsGrid.worksheet().getWidth(c))!==100,c);
+ }
+ await load();await divider(0);await resized(0);
+ assert.equal(requests.length,1);assert.equal(requests[0].col,0);
+ assert.ok(requests[0].size>200,'must measure content below viewport');
+ const first=requests[0].size;
+ await page.reload();await page.waitForFunction(()=>SheetsGrid?.worksheet());
+ assert.equal(Number(await page.evaluate(()=>SheetsGrid.worksheet().getWidth(0))),first,'width survives reload');
+ console.log(name+': single-column divider fits offscreen formatted content and persists');
+ await load();await header(0).click();await header(1).click({modifiers:['Shift']});
+ const selected=await page.evaluate(()=>SheetsGrid.selection());
+ assert.equal(Math.min(selected.c,selected.c2),0);assert.equal(Math.max(selected.c,selected.c2),1);
+ await divider(0);await page.waitForFunction(()=>Number(SheetsGrid.worksheet().getWidth(1))!==100);
+ assert.deepEqual(requests.map(r=>r.col),[0,1]);assert.ok(sizes.cols[0]>sizes.cols[1]);
+ assert.equal(sizes.cols[0],first);
+ console.log(name+': selected columns fit individually without first equalizing widths');
+ // The existing manual group resize must persist both columns too.
+ requests=[];const rect=await header(0).boundingBox();
+ await page.mouse.move(rect.x+rect.width-2,rect.y+rect.height/2);await page.mouse.down();
+ await page.mouse.move(rect.x+rect.width+30,rect.y+rect.height/2);await page.mouse.up();
+ await page.evaluate(()=>SheetsSizes.settled());
+ assert.deepEqual(requests.map(r=>r.col),[0,1]);assert.equal(sizes.cols[0],sizes.cols[1]);
+ console.log(name+': manual group resize persists all columns');
+ await load();await header(2).click();await page.evaluate(()=>SheetsAutoFit());
+ assert.ok(sizes.cols[2]<100,'measure formula result, not raw expression');
+ await header(3).click();await page.evaluate(()=>SheetsAutoFit());assert.equal(sizes.cols[3],2000);
+ await header(4).click();await page.evaluate(()=>SheetsAutoFit());assert.ok(sizes.cols[4]>=16&&sizes.cols[4]<100);
+ console.log(name+': formula display, empty columns, and maximum width');
+ await load();await header(0).click();await header(1).click({modifiers:['Shift']});reject=true;
+ await page.evaluate(()=>SheetsAutoFit());assert.equal(requests.length,1);assert.deepEqual(sizes.cols,{});
+ reject=false;await page.evaluate(()=>SheetsAutoFit());assert.equal(requests.length,3);
+ assert.deepEqual(errors,[]);console.log(name+': failed resize stops and can retry');
+ } finally {await browser.close();}
+}