Fit selected columns to displayed content and persist group resizing
6 files changed,
+216
−17
docs/superpowers/specs/2026-09-16-browser-menus-autofit.md+41 −0internal/sheets/static/autofit.js+76 −0internal/sheets/static/commands.js+1 −0internal/sheets/static/sizes.js+23 −17internal/sheets/templates/sheet.html+2 −0tools/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();} |
| +} |