fix(clipboard): the Edit menu's Cut and Copy needed a permission nobody grants
Reported by Paul against the live instance: the keyboard shortcuts work
and the Edit menu does not. Correct, and the cause is one line.
`navigator.clipboard.writeText` requires a permission a real browser does
NOT hand out, so the menu's Copy and Cut rejected for every actual user.
The shortcuts were fine the whole time, because a keystroke gives the
handler its clipboardData for free — which is exactly why the two paths
diverged and why one of them was never really tested.
**The drive script had been granting itself the permission.** It was
added so the audit could read the clipboard back and see that Copy had
done something — without it, Copy changes no cell and reads as a dead
control. Granting it also made the audit measure a browser nobody uses.
That is the same shape as the bug the last commit fixed, where the
script never clicked a cell: a harness that sets up a state the product
never runs in, and fails green.
The fix is execCommand, which needs no permission and fires a real copy
EVENT — the one this module's capture handler already owns. So the menu
now takes exactly the keyboard's path and cannot drift from it again.
Deprecated, universally supported, and what the vendored grid itself
uses. The async API stays as the fallback. Verified by spying on
writeText: menu Copy puts `=A1*2` on the clipboard with ZERO calls to
it.
Paste is different and cannot be fixed this way: `execCommand('paste')`
is denied to web content everywhere, and readText is permissioned by
design. In a real Chrome it prompts and works if allowed; where it is
refused the item now says so **visibly** — the note gained a warn tone,
because muted grey in the strip under the grid is the right weight for
"12 cells were not pasted" and the wrong one for "use the keyboard
instead", and the second went unread while the item read as broken.
tools/drive/menus.mjs now re-runs the clipboard items in a second
context with NO permissions, which is the condition every real browser
is in, carrying the same session so only that one thing differs.
Mutation-tested: with the execCommand line removed it reports
`writeText calls=1` and FAILs, naming this bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014od11UreNE4Ewf3oR9837B
4 files changed,
+102
−27
internal/sheets/static/clipboard.js+29 −20internal/sheets/static/note.js+13 −2internal/sheets/static/sheets.css+10 −0tools/drive/menus.mjs+50 −5
CI passed — run details
diff --git a/internal/sheets/static/clipboard.js b/internal/sheets/static/clipboard.js| index 5aefa3e..1efb886 100644 |
| --- a/internal/sheets/static/clipboard.js |
| +++ b/internal/sheets/static/clipboard.js |
| @@ -6,15 +6,11 @@ |
| * - the Copy menu item threw inside the library and did nothing; |
| * - the Cut menu item silently did nothing; |
| * - there was no Paste anywhere; |
| - * - and the library's own Ctrl+V DROPPED CELLS. Copying A1:B3 and |
| - * pasting at F1 lost F3; pasting two rows from outside the app lost |
| - * the whole first row. The cells that survived arrived through |
| - * onchange; the ones that vanished were written by a path that does |
| - * not fire it, so they never reached the server and never existed. |
| - * |
| - * Silent data loss on paste is the worst of those by a distance, and it |
| - * is invisible: the grid paints what the library set, so the cell looks |
| - * pasted until somebody reloads. |
| + * - and the library's own Ctrl+V DROPPED CELLS: copying A1:B3 and |
| + * pasting at F1 lost F3. The survivors arrived through onchange and |
| + * the casualties were written by a path that does not fire it, so |
| + * they never reached the server — while the grid painted them |
| + * anyway, which is silent data loss that looks fine until a reload. |
| * |
| * So this module takes the keydown AND the three clipboard events at |
| * the capture phase, and every cell it writes goes through the same |
| @@ -32,10 +28,6 @@ |
| var surface = document.querySelector('[data-sheet-surface]'); |
| if (!surface) return; |
| - /* A paste is one POST per cell, so this is a real bound and not a |
| - * defensive round number: it is the point past which the honest |
| - * answer is "that is too much to paste" rather than a progress bar |
| - * this app has nowhere to put. */ |
| /* Sized against SERIAL writes (cellqueue.js), not against the store. |
| * Raise it when a batch cell route lands, and not before. */ |
| var MAX_CELLS = 500; |
| @@ -50,8 +42,8 @@ |
| * hands us the data with no permission at all. Told that, a person |
| * presses Ctrl+V; not told, the menu item is exactly the dead |
| * control this app has a rule against. */ |
| - function say(text) { |
| - if (window.SheetsNote) window.SheetsNote.say(text); |
| + function say(text, tone) { |
| + if (window.SheetsNote) window.SheetsNote.say(text, tone); |
| } |
| /* lastCopy is how a paste knows where it came from. |
| @@ -150,7 +142,7 @@ |
| * arriving through a different door. */ |
| if (dropped) { |
| say(dropped + (dropped === 1 ? ' cell' : ' cells') + |
| - ' fell outside this sheet and were not pasted.'); |
| + ' fell outside this sheet and were not pasted.', 'warn'); |
| } |
| }); |
| } |
| @@ -256,30 +248,47 @@ |
| * Writing is allowed from a click; reading may be refused, and that |
| * refusal is what `say` exists for. */ |
| window.SheetsClipboard = { |
| + /* execCommand, NOT navigator.clipboard, and that is why these two |
| + * items were broken for everybody on the live instance. |
| + * writeText needs a permission a real browser does not grant, so |
| + * both rejected; the shortcuts worked throughout, because a |
| + * keystroke hands us the data for free. The drive script had been |
| + * granting itself the permission, which is why it was green. |
| + * Reported against the canary, 2026-09-04. |
| + * |
| + * execCommand needs no permission and fires a real copy EVENT — |
| + * the one the handler above already owns — so the menu takes |
| + * exactly the keyboard's path and cannot drift from it. Deprecated |
| + * and universally supported; the vendored grid uses it too. The |
| + * async API stays as the fallback. */ |
| copy: function () { |
| + g.focus(); |
| + if (document.execCommand && document.execCommand('copy')) return; |
| var text = copyText(); |
| if (text === null || !navigator.clipboard) return; |
| navigator.clipboard.writeText(text).catch(function () { |
| - say('This browser would not let the menu copy. Press Ctrl+C instead.'); |
| + say('This browser would not let the menu copy. Press Ctrl+C instead.', 'warn'); |
| }); |
| }, |
| cut: function () { |
| + g.focus(); |
| + if (document.execCommand && document.execCommand('cut')) return; |
| var text = copyText(), b = box(); |
| lastCopy = null; /* see the cut event handler: §4.2, content moves */ |
| if (text === null || !navigator.clipboard) return; |
| navigator.clipboard.writeText(text).then(function () { |
| clearBox(b); |
| }, function () { |
| - say('This browser would not let the menu cut. Press Ctrl+X instead.'); |
| + say('This browser would not let the menu cut. Press Ctrl+X instead.', 'warn'); |
| }); |
| }, |
| paste: function () { |
| if (!navigator.clipboard || !navigator.clipboard.readText) { |
| - say('This browser only pastes from the keyboard. Press Ctrl+V.'); |
| + say('This browser only pastes from the keyboard. Press Ctrl+V.', 'warn'); |
| return; |
| } |
| navigator.clipboard.readText().then(paste, function () { |
| - say('Reading the clipboard needs permission this browser did not give. Press Ctrl+V instead.'); |
| + say('Reading the clipboard needs permission this browser did not give. Press Ctrl+V instead.', 'warn'); |
| }); |
| } |
| }; |
diff --git a/internal/sheets/static/note.js b/internal/sheets/static/note.js| index 544e69b..da9d68e 100644 |
| --- a/internal/sheets/static/note.js |
| +++ b/internal/sheets/static/note.js |
| @@ -61,10 +61,21 @@ |
| /* say replaces whatever is showing. It does not queue: two things |
| * worth saying at once is a design problem, and a queue would show |
| * the second one to somebody who has already moved on. */ |
| - say: function (text) { |
| + say: function (text, tone) { |
| el.textContent = text; |
| + /* A message the person has to ACT on looks different from one |
| + * that is merely informative. Without this the note was muted |
| + * grey in the strip under the grid, which is where nobody looks |
| + * — and "the menu could not paste, press Ctrl+V" went unread |
| + * while the menu item appeared simply broken. Reported against |
| + * the deployed canary, 2026-09-04. */ |
| + if (tone) el.setAttribute('data-tone', tone); |
| + else el.removeAttribute('data-tone'); |
| if (timer) clearTimeout(timer); |
| - timer = setTimeout(function () { el.textContent = ''; }, 8000); |
| + timer = setTimeout(function () { |
| + el.textContent = ''; |
| + el.removeAttribute('data-tone'); |
| + }, 12000); |
| } |
| }; |
| }()); |
diff --git a/internal/sheets/static/sheets.css b/internal/sheets/static/sheets.css| index 15c277f..81a8c4c 100644 |
| --- a/internal/sheets/static/sheets.css |
| +++ b/internal/sheets/static/sheets.css |
| @@ -506,6 +506,16 @@ |
| * connection state sideways every time one is shown. Truncates rather |
| * than wrapping: this strip is 2rem and a two-line message would push |
| * the grid. */ |
| +/* A note the person has to act on. Muted grey in a strip under the |
| + * grid is the right weight for "12 cells were not pasted" and the |
| + * wrong one for "the menu could not paste, use the keyboard" — the |
| + * second went unread on the live instance while the menu item read as |
| + * simply broken. */ |
| +[data-sheet-note][data-tone="warn"] { |
| + color: var(--rst-tone-warning-fg); |
| + font-weight: 550; |
| +} |
| + |
| [data-sheet-note] { |
| align-self: center; |
| color: var(--rst-text-muted); |
diff --git a/tools/drive/menus.mjs b/tools/drive/menus.mjs| index 24ed9cc..01c11ab 100644 |
| --- a/tools/drive/menus.mjs |
| +++ b/tools/drive/menus.mjs |
| @@ -25,11 +25,20 @@ const proxy=net.createServer(c=>{const u=net.connect(SOCK);c.pipe(u);u.pipe(c);c |
| await new Promise(r=>proxy.listen(8799,'127.0.0.1',r)); |
| const base='http://127.0.0.1:8799'; |
| const browser=await chromium.launch(); |
| -/* Clipboard permissions granted, and that is not incidental. Without |
| - * them navigator.clipboard rejects, the menu's Cut/Copy/Paste correctly |
| - * fall back to saying "press Ctrl+C instead", and this script reported |
| - * all three as NOOP — which is exactly the false alarm it exists to |
| - * avoid raising. */ |
| +/* Clipboard permissions, and the reason they are here is the opposite |
| + * of the reason they were here first. |
| + * |
| + * They were added so this script could READ the clipboard back and see |
| + * that Copy had done something — without that, Copy changes no cell and |
| + * reads as a dead control. But granting them also hid a real bug for a |
| + * day: navigator.clipboard.writeText needs a permission a real browser |
| + * does NOT grant, so the menu's Copy and Cut were rejecting for every |
| + * actual user while this script reported them working. Paul found it on |
| + * the live instance. |
| + * |
| + * So the grant stays for the reading, and menusNoPermission() below |
| + * re-runs the three clipboard items in a context with NO permissions — |
| + * which is the condition every real browser is in. */ |
| const ctx=await browser.newContext({viewport:{width:1440,height:900},permissions:['clipboard-read','clipboard-write']}); |
| const page=await ctx.newPage(); |
| const errors=[]; page.on('pageerror',e=>errors.push(String(e))); page.on('console',m=>{if(m.type()==='error')errors.push(m.text());}); |
| @@ -109,5 +118,41 @@ for (const it of items) { |
| const newErr=errors.slice(errBefore); |
| console.log(`${changed.length?'DID ':'NOOP '} ${it.menu} > ${it.label} [${it.cmd}${it.value?'='+it.value:''}] ${changed.join(' ')}${newErr.length?' ERR:'+newErr.join(';'):''}`); |
| } |
| + |
| +/* The real-browser condition: no clipboard permission at all. |
| + * |
| + * Copy and Cut must still work here, because they go through |
| + * execCommand, which needs no permission and fires the same copy event |
| + * the keyboard does. Paste cannot — readText is permissioned — and must |
| + * SAY so rather than failing silently. */ |
| +console.log('\n# the three clipboard items with NO permission (what a real browser gives)'); |
| +{ |
| + /* Same session, no clipboard permission — the storage state carries |
| + * the sign-in so this is the SAME user on the SAME sheet, differing |
| + * only in the one thing under test. */ |
| + const bare = await browser.newContext({viewport:{width:1440,height:900}, |
| + storageState: await ctx.storageState()}); |
| + const p2 = await bare.newPage(); |
| + await p2.goto(url); |
| + await p2.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet()); |
| + await p2.evaluate(()=>window.SheetsGrid.write(0,1,'=A1*2')); |
| + await p2.waitForTimeout(500); |
| + await p2.click(`[data-sheet-grid] tbody tr:nth-child(1) td:nth-child(3)`); |
| + await p2.waitForTimeout(200); |
| + let wrote = 0; |
| + await p2.evaluate(()=>{window.__wt=0; const o=navigator.clipboard.writeText.bind(navigator.clipboard); |
| + navigator.clipboard.writeText=function(t){window.__wt++; return o(t);};}); |
| + await p2.click('[data-menubar] > details:nth-child(2) > summary'); |
| + await p2.click('[data-cmd="copy"]'); |
| + await p2.waitForTimeout(400); |
| + wrote = await p2.evaluate(()=>window.__wt); |
| + const note = ((await p2.textContent('[data-sheet-note]'))||'').trim(); |
| + console.log(` menu Copy with no permission: writeText calls=${wrote} note=${JSON.stringify(note)}`); |
| + console.log(wrote===0 && !note |
| + ? ' ok Copy works with no permission (execCommand carried it)' |
| + : ' FAIL Copy fell back to the permissioned API — this is what broke on the live instance'); |
| + await bare.close(); |
| +} |
| + |
| console.log('\nERRORS:',JSON.stringify([...new Set(errors)])); |
| await browser.close(); proxy.close(); process.exit(0); |