oficina / sheets Public

fix(chrome): menus opened against the viewport edge, and a 40px gap

Three screen bugs Paul reported, all measured in a real browser rather
than reasoned about, and all three of a kind: two stylesheets that each
read correctly and disagree only once composed.

**Every menu opened flush right.** sheets.css overrode rastrillo's
dropdown placement with `inset-inline-start: 0; inset-inline-end: auto`,
written against the framework's pre-anchor `position: absolute` panel.
rastrillo v0.23.0 anchors it instead — `position: fixed`,
`position-anchor`, a `position-area`, and `inset: auto` so the area does
the placing — and that `inset: auto` is (0,1,0) against our (0,2,0). So
ours won inside the anchor branch too, where a resolved inset means the
VIEWPORT is the containing block. Measured on a 1280px viewport: all six
panels at x=1015, whichever word you clicked. Fixed by handing placement
back and changing the AREA instead: `span-inline-end`, because a menu
bar sits at the left and opens rightward where an account menu does the
reverse. rastrillo's position-try-fallbacks still apply on top, so a menu
near a narrow viewport's edge flips rather than running off — verified
at 320px, where the document no longer scrolls sideways either.

**40px to the left of every thumbnail.** The sheet list is a `<ul
rst-list>` and rastrillo's [rst-list] is written for a `<div>`: it sets
background, border and radius, and resets nothing, because a `<div>` has
nothing to reset. The UA stylesheet's `padding-inline-start: 40px` and
`list-style: disc` both survived. The element stays a `<ul>` — a list of
sheets is a list — and the block margin is restated from the token scale
rather than zeroed, since nothing in rastrillo stacks [rst-page]'s
children and that margin was the gap above the card.

**The Format panel was ragged.** `[rst-dropdown-menu] > form { display:
contents }` is right for the scriptless undo POST and wrong for the two
resize forms, which are one control each and lay themselves out in a
flex row. At (0,3,0) against (0,1,0) it won: the form stopped generating
a box, its label, field and button became three menu items, and the
panel rendered "Column width" and "px" on one line with a full-width
"Set" beneath. Excluded by selector, plus a fixed label column so the
two rows line up, a 17rem panel so nothing wraps, and a taller cap so
"Clear formatting" is no longer behind a scrollbar on a 900px screen.

The four submenus gained the chevron they never had. Alignment and Bold
were the same shape and only one of them expanded.

**The guard.** TestEveryDropdownInsetOverrideHasAnAnchorBranch pins the
first bug structurally: an override of rastrillo's pre-anchor insets
must be paired with an anchor-branch rule for the same selector. Nothing
else could see it — the suite passes, the CSS parses, every token is
real, and each declaration is individually correct. Mutation-tested both
ways: deleting the branch and adding a new unpaired override each fail
with the selector named.

tools/drive/menus.mjs is the browser audit that found all of this. It
clicks all 54 menu items and prints LINK, FORM, DID or NOOP for each.
Two come back NOOP today — Cut and Copy — which is the next commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014od11UreNE4Ewf3oR9837B
Paul Campbell pushed by paul@keymail.dev 1434f464c4a30649ceb5ba72732d13077785e272 parent 8a27bfc
4 files changed, +425 −11
  • internal/guardstest/dropdownanchor_test.go +180 −0
  • internal/sheets/static/sheets.css +146 −6
  • internal/sheets/templates/sheet.html +5 −5
  • tools/drive/menus.mjs +94 −0

CI passed — run details

diff --git a/internal/guardstest/dropdownanchor_test.go b/internal/guardstest/dropdownanchor_test.go
new file mode 100644
index 0000000..b921ad9
--- /dev/null
+++ b/internal/guardstest/dropdownanchor_test.go
@@ -0,0 +1,180 @@
+package guards
+
+import (
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+)
+
+// This is the guard for a bug that made every menu in the app open
+// against the right-hand edge of the viewport while both stylesheets
+// read as correct.
+//
+// rastrillo v0.23.0 places a dropdown panel with CSS anchor
+// positioning: `position: fixed`, `position-anchor: --rst-menu`, a
+// `position-area`, and `inset: auto` so that the area does the placing.
+// Its pre-anchor fallback — `position: absolute; top: 100%;
+// inset-inline-end: 0` — is still in the file for engines without it.
+//
+// Sheets overrides that fallback, because rastrillo's panel opens
+// leftward (right for the account menu it was written for) and a menu
+// bar opens rightward. The override was written as `inset-inline-start:
+// 0; inset-inline-end: auto` at `[data-menubar] [rst-dropdown-menu]`,
+// which is (0,2,0) — and rastrillo's `inset: auto` is (0,1,0). So ours
+// won inside the @supports branch too, where it means something else
+// entirely: an anchored, fixed panel with a resolved inline-start inset
+// takes the VIEWPORT as its containing block. Measured on 2026-09-03,
+// all six menus opened at x=1015 on a 1280px viewport, whichever word
+// was clicked.
+//
+// Nothing could see it. The Go suite passes, the CSS parses, every
+// token is real, and each of the two declarations is individually the
+// right thing to write. Only a browser measuring a rectangle finds it,
+// and CI has no browser.
+//
+// So the invariant is structural, and it is the one thing that was
+// missing: an override of rastrillo's PRE-ANCHOR insets must be paired
+// with an anchor-branch rule for the same selector that hands the
+// placing back. Adding a third menu-ish panel and styling it the
+// obvious way now fails here rather than in a screenshot nobody takes.
+const sheetsCSS = "internal/sheets/static/sheets.css"
+
+var (
+ // A rule head plus its body, non-greedy. sheets.css nests only
+ // inside @supports, and stripBlocks below removes those first, so a
+ // body never contains a brace.
+ cssRule = regexp.MustCompile(`(?s)([^{}@]+)\{([^{}]*)\}`)
+ // The @supports blocks, with their condition and their contents.
+ cssSupports = regexp.MustCompile(`(?s)@supports([^{]*)\{(.*?\n\})\s*\n`)
+ cssComment = regexp.MustCompile(`(?s)/\*.*?\*/`)
+ insetLogical = regexp.MustCompile(`(?m)^\s*inset-inline-(start|end)\s*:`)
+ insetAuto = regexp.MustCompile(`(?m)^\s*inset\s*:\s*auto\s*;`)
+)
+
+// selectorsOverridingPanelInsets returns every selector in the given
+// CSS that targets a rastrillo dropdown panel and sets one of the
+// logical insets rastrillo's pre-anchor fallback uses.
+func selectorsOverridingPanelInsets(css string) []string {
+ var out []string
+ for _, m := range cssRule.FindAllStringSubmatch(css, -1) {
+ sel := strings.Join(strings.Fields(m[1]), " ")
+ if !strings.Contains(sel, "[rst-dropdown-menu]") {
+ continue
+ }
+ if insetLogical.MatchString(m[2]) {
+ out = append(out, sel)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// anchorBranchSelectors returns every selector that appears inside an
+// @supports block testing position-area and hands placement back with
+// `inset: auto`.
+func anchorBranchSelectors(css string) map[string]bool {
+ out := map[string]bool{}
+ for _, blk := range cssSupports.FindAllStringSubmatch(css, -1) {
+ if !strings.Contains(blk[1], "position-area") {
+ continue
+ }
+ for _, m := range cssRule.FindAllStringSubmatch(blk[2], -1) {
+ if !insetAuto.MatchString(m[2]) {
+ continue
+ }
+ out[strings.Join(strings.Fields(m[1]), " ")] = true
+ }
+ }
+ return out
+}
+
+func TestEveryDropdownInsetOverrideHasAnAnchorBranch(t *testing.T) {
+ css := cssComment.ReplaceAllString(readRepoFile(t, repoRoot(t), sheetsCSS), "")
+
+ overrides := selectorsOverridingPanelInsets(css)
+ if len(overrides) == 0 {
+ t.Fatalf("guards: found no [rst-dropdown-menu] inset override in %s. "+
+ "Either the app stopped overriding rastrillo's placement — in which "+
+ "case delete this test and say why — or the parser above has gone "+
+ "blind and is now proving nothing.", sheetsCSS)
+ }
+
+ anchored := anchorBranchSelectors(css)
+ if len(anchored) == 0 {
+ t.Fatalf("guards: %s overrides %d dropdown panel inset(s) and declares no "+
+ "@supports (position-area …) branch at all. In Chromium those "+
+ "overrides do not adjust rastrillo's placement, they replace it with "+
+ "the viewport: every menu opens flush against one edge of the window "+
+ "whichever word was clicked.", sheetsCSS, len(overrides))
+ }
+
+ for _, sel := range overrides {
+ if !anchored[sel] {
+ t.Errorf("%s sets inset-inline-* on %q but no @supports "+
+ "(position-area …) block resets `inset: auto` for that same "+
+ "selector.\n"+
+ "That override is written against rastrillo's PRE-ANCHOR "+
+ "fallback. Where anchor positioning exists it outranks "+
+ "rastrillo's own `inset: auto` on specificity — (0,2,0) beats "+
+ "(0,1,0) — and an anchored fixed panel with a resolved inset "+
+ "is placed against the viewport, not against the summary it "+
+ "hangs off.\n"+
+ "Pair it: keep this rule for the fallback, and add\n"+
+ " @supports (position-area: block-end) and "+
+ "(position-try-fallbacks: flip-block) {\n"+
+ " %s { inset: auto; position-area: block-end span-inline-end; }\n"+
+ " }",
+ sheetsCSS, sel, sel)
+ }
+ }
+}
+
+// TestTheAnchorGuardReadsTheStylesheetItThinksItDoes is the mutation
+// check for the two parsers above.
+//
+// Both read CSS with a regex, which is the kind of check that silently
+// stops matching after a reformat and then passes forever. This feeds
+// them CSS with the bug present and requires them to see it, so a
+// parser that has gone blind fails here and names itself rather than
+// turning the test above into a no-op that nobody notices.
+func TestTheAnchorGuardReadsTheStylesheetItThinksItDoes(t *testing.T) {
+ const broken = `
+[data-menubar] [rst-dropdown-menu] {
+ inset-inline-start: 0;
+ inset-inline-end: auto;
+}
+`
+ if got := selectorsOverridingPanelInsets(broken); len(got) != 1 {
+ t.Fatalf("the override parser did not see the exact shape of the shipped "+
+ "bug: got %v, want one selector", got)
+ }
+ if got := anchorBranchSelectors(broken); len(got) != 0 {
+ t.Fatalf("the anchor-branch parser invented a branch that is not there: %v", got)
+ }
+
+ const fixed = broken + `
+@supports (position-area: block-end) and (position-try-fallbacks: flip-block) {
+ [data-menubar] [rst-dropdown-menu] {
+ inset: auto;
+ position-area: block-end span-inline-end;
+ }
+}
+`
+ if got := anchorBranchSelectors(fixed); !got["[data-menubar] [rst-dropdown-menu]"] {
+ t.Fatalf("the anchor-branch parser did not find the paired branch: %v", got)
+ }
+
+ // A branch guarded by some OTHER @supports condition must not count.
+ const wrongCondition = broken + `
+@supports (display: grid) {
+ [data-menubar] [rst-dropdown-menu] {
+ inset: auto;
+ }
+}
+`
+ if got := anchorBranchSelectors(wrongCondition); len(got) != 0 {
+ t.Fatalf("an @supports block that does not test position-area was accepted "+
+ "as the anchor branch: %v", got)
+ }
+}
diff --git a/internal/sheets/static/sheets.css b/internal/sheets/static/sheets.css
index 66571cb..de4a39e 100644
--- a/internal/sheets/static/sheets.css
+++ b/internal/sheets/static/sheets.css
@@ -97,23 +97,85 @@
/* rastrillo's dropdown panel is right-aligned, which is right for the
* account menu it was written for and wrong for a menu bar: a File menu
* hanging off the right edge of the word "File" opens away from the
- * item you clicked. */
+ * item you clicked.
+ *
+ * This is the fallback branch, for an engine with no anchor
+ * positioning. There rastrillo's panel is `position: absolute;
+ * top: 100%; inset-inline-end: 0` and flipping the two insets is the
+ * whole fix. The @supports block below is the branch that actually
+ * runs in Chromium, and it must not be folded into this rule — see
+ * its own comment for what happens when it is. */
[data-menubar] [rst-dropdown-menu] {
inset-inline-start: 0;
inset-inline-end: auto;
- min-inline-size: 15rem;
+ /* 17rem, not the 15rem this started at, and the extra 2rem is spent
+ * on the two resize rows: label, field and button need 236px side by
+ * side, and 15rem leaves 224. Widening the panel is the honest fix —
+ * squeezing the field instead makes a number box you cannot read a
+ * three-digit value in. */
+ min-inline-size: 17rem;
/* 15rem is 240px, and the menus nearest the right edge open from
* there — so on a 320px viewport an open panel ran off the screen
* even though the closed chrome fits. max wins over min in CSS, so
* this bounds it without the panel becoming narrow on a wide one. */
max-inline-size: calc(100vw - 1rem);
+ /* rastrillo caps a panel at 20rem and scrolls past it, which is right
+ * for a list nobody can predict the length of — a locale menu, a row
+ * kebab. A menu bar's menus are a FIXED list we wrote, and Format's
+ * closed height is 394px: the cap put "Clear formatting" behind a
+ * scrollbar on every viewport, including a 900px-tall one with room
+ * to spare. The dvh term is rastrillo's own and stays, so a short
+ * window still scrolls rather than overflowing. */
+ max-block-size: min(30rem, max(calc(100dvh - 6rem), 8rem));
+}
+
+/* Where anchor positioning exists, the two insets above are not a
+ * refinement of rastrillo's placement — they DEFEAT it, and this is
+ * the bug that put every menu against the right edge of the viewport.
+ *
+ * rastrillo v0.23.0 anchors its panel: `position: fixed`,
+ * `position-anchor: --rst-menu`, `position-area: block-end
+ * span-inline-start`, and `inset: auto` so the area does the placing.
+ * That `inset: auto` is (0,1,0). Ours above is (0,2,0), so it wins —
+ * and an anchored, fixed panel with inset-inline-start resolved to 0
+ * takes the VIEWPORT as its containing block. Measured 2026-09-03 on a
+ * 1280px viewport: all six panels landed at x=1015, flush right,
+ * whichever word you clicked. The menu bar looked broken and the CSS
+ * looked correct.
+ *
+ * So the fix is to hand the placement back and change the AREA, not
+ * the insets. `span-inline-end` is the one word that differs from
+ * rastrillo's: its account menu sits at the right edge and opens
+ * leftward, and a menu bar sits at the left and opens rightward.
+ *
+ * rastrillo's `position-try-fallbacks` still applies on top, so a menu
+ * near the right edge of a narrow viewport still flips rather than
+ * running off — which is why this is a change of base area and not of
+ * `position-try-fallbacks` too. Restating the @supports condition
+ * rather than nesting: the two must test the same thing, and an engine
+ * that supports one half of it would otherwise get neither branch. */
+@supports (position-area: block-end) and (position-try-fallbacks: flip-block) {
+ [data-menubar] [rst-dropdown-menu] {
+ inset: auto;
+ position-area: block-end span-inline-end;
+ }
}
/* An item is a row with its shortcut pushed to the far end, which is
* what makes a menu readable as a table of keys as well as a list of
* actions. The form wrapper around a scriptless POST is display:contents
- * so its button lays out as though the form were not there. */
-[data-menubar] [rst-dropdown-menu] > form {
+ * so its button lays out as though the form were not there.
+ *
+ * :not([data-size-form]) is load-bearing, and its absence is what made
+ * the Format menu ragged. A resize form is not a wrapper around one
+ * menu item — it is a label, a number field and a button that read as
+ * ONE control, and [data-size-form] lays them out in a flex row to say
+ * so. This rule is (0,3,0) and that one is (0,1,0), so display:contents
+ * won: the form stopped generating a box, its three children became
+ * menu items in their own right, and the panel rendered "Column width"
+ * and "px" on one line with a full-width "Set" under them. Both halves
+ * looked correct in isolation. */
+[data-menubar] [rst-dropdown-menu] > form:not([data-size-form]) {
display: contents;
}
@@ -125,6 +187,27 @@
justify-content: space-between;
}
+/* A submenu summary is an item too — the same row, the same hover, plus
+ * the one thing that distinguishes it: a chevron saying it opens rather
+ * than does. rastrillo styles [rst-menu-group] > summary with padding
+ * and a muted colour and stops there, so Alignment and Bold were the
+ * same shape and only one of them expanded.
+ *
+ * The chevron is real markup, not a ::after character, so rastrillo's
+ * own `details[open] > summary > [rst-caret]` rotates it on open and
+ * the state is visible rather than remembered. */
+[data-menubar] [rst-dropdown-menu] [rst-menu-group] > summary {
+ align-items: center;
+ border-radius: var(--rst-radius-sm);
+ display: flex;
+ gap: var(--rst-sp-4);
+ justify-content: space-between;
+}
+
+[data-menubar] [rst-dropdown-menu] [rst-menu-group] > summary:hover {
+ background: var(--rst-accent-soft);
+}
+
[data-menubar] kbd {
color: var(--rst-text-faint);
font-family: var(--rst-font);
@@ -243,11 +326,21 @@
display: none;
}
+/* The same pair as the menu bar's, for the same reason and with the
+ * same two branches. A toolbar's colour picker opens under its own
+ * button, not off the right edge of the window. */
[data-tool-menu] [rst-dropdown-menu] {
inset-inline-start: 0;
inset-inline-end: auto;
}
+@supports (position-area: block-end) and (position-try-fallbacks: flip-block) {
+ [data-tool-menu] [rst-dropdown-menu] {
+ inset: auto;
+ position-area: block-end span-inline-end;
+ }
+}
+
[data-tool] [rst-caret] svg {
block-size: 0.75rem;
inline-size: 0.75rem;
@@ -939,6 +1032,36 @@
* The slot is reserved whether or not there is anything to draw: an
* empty sheet renders the empty bordered box, so a row never changes
* height when a sheet gains its first cell. */
+/* The list card is a <ul> and rastrillo's [rst-list] is written for a
+ * <div> — its comment says "a list card holds rows", and every rule it
+ * declares is background, border and radius. It resets nothing, because
+ * a <div> has nothing to reset.
+ *
+ * A <ul> does. The UA stylesheet gives it `padding-inline-start: 40px`
+ * and `list-style: disc`, and both survived: the 40px is the gap Paul
+ * saw to the left of the thumbnail on 2026-09-03, and it is 40px of
+ * card that every row is indented inside. The markers were invisible
+ * only because they render in that padding, outside the row's own box.
+ *
+ * The element stays a <ul>. A list of sheets is a list, and the fix for
+ * a framework rule that assumed a <div> is to reset what the <div>
+ * never had — not to drop the semantics that make the list announce as
+ * one to a screen reader.
+ *
+ * The block margin is REPLACED rather than removed. Nothing in
+ * rastrillo stacks the children of [rst-page] — there is no
+ * `> * + *` rule — so what separated this card from the import form
+ * above it was the <ul>'s own UA `margin-block: 1em`. Zeroing the
+ * shorthand would have closed that gap as a side effect of fixing the
+ * padding, so the margin is restated from the token scale, which is
+ * where every other gap on the page comes from. */
+[data-sheet-list] {
+ list-style: none;
+ margin-block: var(--rst-sp-4);
+ margin-inline: 0;
+ padding: 0;
+}
+
/* [rst-lrow] is a grid, and --rst-cols is rastrillo's own hook for
* saying what its columns are. Setting it is how a row gains a
* thumbnail without overriding anything: without it the row has one
@@ -978,12 +1101,29 @@
padding: var(--rst-sp-1) var(--rst-sp-2);
}
+/* nowrap is the point of the three rules below, and they are sized
+ * backwards from it. The panel is 15rem, so a row has 224px of content
+ * box; at 4rem of field and a button padded to its word, the label is
+ * left about 100px and "Column width" sets on one line. At the numbers
+ * this started with — a 4.5rem field and a default-padded button — it
+ * had 48px and broke over two lines, and the two forms then disagreed
+ * about their own height. */
+/* A fixed label column, not flex: 1. The two forms are siblings and
+ * cannot share a grid, so the only thing that lines their fields and
+ * their two Set buttons up is both labels reserving the same width —
+ * with flex: 1 each label took its own text's width and the rows sat
+ * 33px out of step with each other. */
[data-size-form] label {
color: var(--rst-text-muted);
- flex: 1;
+ flex: 0 0 7rem;
font-size: var(--rst-fs-sm);
+ white-space: nowrap;
}
[data-size-form] input {
- inline-size: 4.5rem;
+ inline-size: 4rem;
+}
+
+[data-size-form] button {
+ padding-inline: var(--rst-sp-2);
}
diff --git a/internal/sheets/templates/sheet.html b/internal/sheets/templates/sheet.html
index 4220f2c..358e59c 100644
--- a/internal/sheets/templates/sheet.html
+++ b/internal/sheets/templates/sheet.html
@@ -126,7 +126,7 @@
{{if .Data.MayWrite}}
{{/* No name= — see the note above the File menu. */}}
<details rst-menu-group hidden data-needs-js>
-<summary>Function</summary>
+<summary>Function<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary>
<div>
{{/* Exactly the aggregates the engine registers and that a
one-click insert can guess the arguments for. VLOOKUP, INDEX
@@ -158,7 +158,7 @@
<button type="button" data-cmd="italic">Italic <kbd>Ctrl I</kbd></button>
<hr>
<details rst-menu-group>
-<summary>Alignment</summary>
+<summary>Alignment<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary>
<div>
<button type="button" data-cmd="align" data-value="left">Left</button>
<button type="button" data-cmd="align" data-value="center">Centre</button>
@@ -166,7 +166,7 @@
</div>
</details>
<details rst-menu-group>
-<summary>Number</summary>
+<summary>Number<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary>
<div>
{{/* Ranged over the list the write endpoint validates against, so a
button the UI renders is a pattern the route accepts — by
@@ -177,7 +177,7 @@
</div>
</details>
<details rst-menu-group>
-<summary>Fill colour</summary>
+<summary>Fill colour<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary>
<div data-swatches>
<button type="button" data-cmd="fill" data-value="none">No fill</button>
{{range .Data.FillSwatches}}
@@ -187,7 +187,7 @@
</div>
</details>
<details rst-menu-group>
-<summary>Text colour</summary>
+<summary>Text colour<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary>
<div data-swatches>
<button type="button" data-cmd="ink" data-value="none">Default</button>
{{range .Data.InkSwatches}}
diff --git a/tools/drive/menus.mjs b/tools/drive/menus.mjs
new file mode 100644
index 0000000..e97e25b
--- /dev/null
+++ b/tools/drive/menus.mjs
@@ -0,0 +1,94 @@
+/* Click every menu item and record what it changed.
+ *
+ * The sibling of chrome.mjs, and the one that answers a question no Go
+ * test can: does this control DO anything. TestEveryMenuCommandIsImplemented
+ * proves a `data-cmd` has an entry in commands.js's dispatch table; it
+ * cannot prove the entry works. On 2026-09-03 two of them did not —
+ * Copy threw inside the vendored grid and Cut silently no-opped — and
+ * the guard was green the whole time.
+ *
+ * It prints one line per item: LINK, FORM, DID or NOOP. A NOOP on a
+ * [data-cmd] item is the failure this exists to surface.
+ *
+ * Not part of `make test`: it needs a browser and a running instance,
+ * neither of which CI has. See drive.mjs for the full recipe.
+ *
+ * SHEETS_SOCK=$D/s.sock SHEETS_LOG=$D/s.log \
+ * PLAYWRIGHT=/path/to/playwright/index.js node tools/drive/menus.mjs
+ */
+import net from 'node:net';
+import fs from 'node:fs';
+const pw = await import(process.env.PLAYWRIGHT);
+const chromium = pw.chromium || (pw.default && pw.default.chromium);
+const SOCK=process.env.SHEETS_SOCK, LOG=process.env.SHEETS_LOG, OUT=process.env.OUT||'.';
+const proxy=net.createServer(c=>{const u=net.connect(SOCK);c.pipe(u);u.pipe(c);c.on('error',()=>u.destroy());u.on('error',()=>c.destroy());});
+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();
+const ctx=await browser.newContext({viewport:{width:1440,height:900}});
+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());});
+const reqs=[]; page.on('request',r=>{if(r.method()!=='GET')reqs.push(r.method()+' '+new URL(r.url()).pathname);});
+
+await page.goto(`${base}/signin`); await page.fill('input[type=email]','paul@example.com');
+await Promise.all([page.waitForLoadState('networkidle'),page.click('button[type=submit]')]);
+const mm=[...fs.readFileSync(LOG,'utf8').matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop();
+await page.goto(mm[0]); await page.waitForURL(/\/sheets/);
+if(!(await page.$('[data-sheet-list]'))){await page.fill('#title','Audit');await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('form[action="/sheets"] button[type=submit]')]);}
+else {await page.click('[data-sheet-list] [rst-row-main] a'); await page.waitForURL(/\/sheets\/\d+$/);}
+const url=page.url();
+await page.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet());
+const seed=async()=>{for(const [r,c,v] of [[0,0,'10'],[1,0,'20'],[2,0,'30'],[3,0,'=SUM(A1:A3)'],[0,1,'alpha'],[1,1,'beta']])
+ await page.evaluate(([r,c,v])=>window.SheetsGrid.write(r,c,v),[r,c,v]); await page.waitForTimeout(400);};
+await seed();
+
+/* Snapshot: every cell's raw input plus the anchor's style. */
+const snap=()=>page.evaluate(()=>{const g=window.SheetsGrid;const out=[];
+ for(let r=0;r<6;r++)for(let c=0;c<3;c++){const v=g.rawAt(r,c);if(v!=='')out.push(`${r},${c}=${v}`);}
+ const sel=g.selection();
+ return {cells:out.join('|'), style:JSON.stringify(sel?g.styleAt(sel.r,sel.c)||{}:{}),
+ sel:sel?`${sel.r},${sel.c}-${sel.r2},${sel.c2}`:'none'};});
+
+async function select(r,c,r2,c2){await page.evaluate(([r,c,r2,c2])=>window.SheetsGrid.worksheet().updateSelectionFromCoords(c,r,c2===undefined?c:c2,r2===undefined?r:r2),[r,c,r2,c2]);await page.waitForTimeout(150);}
+
+const items=await page.evaluate(()=>{
+ const out=[];
+ document.querySelectorAll('[data-menubar] > details').forEach((d,mi)=>{
+ const menu=d.querySelector(':scope > summary').textContent.trim();
+ d.querySelectorAll('[rst-dropdown-menu] a, [rst-dropdown-menu] button').forEach(el=>{
+ out.push({menu, mi, label:el.textContent.trim().replace(/\s+/g,' ').slice(0,28),
+ kind:el.tagName, cmd:el.dataset.cmd||'', value:el.dataset.value!==undefined?el.dataset.value:(el.dataset.fn||''),
+ href:el.getAttribute('href')||'', inForm:!!el.closest('form'),
+ sub:(el.closest('details[rst-menu-group]')||{}).firstElementChild?.textContent?.trim()||''});
+ });
+ });
+ return out;});
+console.log(`# ${items.length} menu items\n`);
+
+for (const it of items) {
+ if (it.href) { console.log(`LINK ${it.menu} > ${it.label} -> ${it.href}`); continue; }
+ if (it.inForm && !it.cmd) { console.log(`FORM ${it.menu} > ${it.label} (scriptless POST)`); continue; }
+ await page.goto(url); await page.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet());
+ await page.waitForTimeout(250);
+ await select(1,0,2,0);
+ const before=await snap(); reqs.length=0;
+ const errBefore=errors.length;
+ try {
+ await page.click(`[data-menubar] > details:nth-child(${it.mi+1}) > summary`);
+ if (it.sub) await page.click(`[data-menubar] > details:nth-child(${it.mi+1}) details[rst-menu-group] >> summary:text-is(${JSON.stringify(it.sub)})`);
+ const sel=`[data-menubar] > details:nth-child(${it.mi+1}) [rst-dropdown-menu] [data-cmd="${it.cmd}"]`+
+ (it.value?`[data-value="${it.value}"], [data-menubar] > details:nth-child(${it.mi+1}) [rst-dropdown-menu] [data-cmd="${it.cmd}"][data-fn="${it.value}"]`:'');
+ await page.click(sel+' >> nth=0', {timeout:5000});
+ } catch(e){ console.log(`ERR ${it.menu} > ${it.label} (${String(e).split('\n')[0].slice(0,70)})`); continue; }
+ await page.waitForTimeout(600);
+ const after=await snap();
+ const changed=[];
+ if(before.cells!==after.cells)changed.push('cells');
+ if(before.style!==after.style)changed.push('style');
+ if(before.sel!==after.sel)changed.push('selection');
+ if(reqs.length)changed.push('posted:'+[...new Set(reqs)].join(','));
+ 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(';'):''}`);
+}
+console.log('\nERRORS:',JSON.stringify([...new Set(errors)]));
+await browser.close(); proxy.close(); process.exit(0);