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
4 files changed,
+425
−11
internal/guardstest/dropdownanchor_test.go+180 −0internal/sheets/static/sheets.css+146 −6internal/sheets/templates/sheet.html+5 −5tools/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); |