oficina / sheets Public

fix(chrome): four defects an independent review found in the phone pass

Reviewed by GPT-5.1-Codex against the branch. All four are real; the
first was a hard functional break.

FILL AND TEXT COLOUR WERE UNREACHABLE ON A PHONE. The "..." carried
name="rst-tools", the same exclusive group as the two colour menus —
which sit INSIDE the panel it reveals. Opening Fill colour closed the
disclosure, which hid the panel, which took the colour menu down with
it. Same shape from the other side: rst-dropdown put the control in
rastrillo.js's MENUS, whose click-away closes any open menu that does
not CONTAIN the click, and every tap on a swatch or an alignment button
is outside a control whose panel is its SIBLING — so the panel also shut
under the finger on every single use, and Escape from inside it left
focus on an element no longer rendered.

One root cause: the panel has to be a sibling (a closed <details> hides
its own content, and the wide layout cannot be recovered from inside
one), and every containment check in the framework asks the wrong
question about a control shaped like that. So it is a bare <details>
now, invisible to rastrillo.js on purpose, with click-away and Escape in
static/toolbar.js that test containment against the whole strip.

THE ARROW KEYS WALKED HIDDEN CONTROLS. commands.js's roving tabindex
took every [data-tool] with no visibility filter, so right-arrow from
Italic parked the strip's only tab stop on the hidden Fill colour
summary and called focus() on it, which does nothing — after which Tab
skipped the toolbar entirely. It now walks what is on screen, and
normalises the stop instead of trusting that whatever set one demoted
the rest.

THE CLIPPED BRAND WAS AN INVISIBLE FOCUS STOP. Below 800px the app name
was hidden with the 1px clip [rst-skip] uses. [rst-skip] can do that
because it un-clips on :focus-visible; this did not, so a keyboard user
tabbing the bar landed on a control one pixel square with nothing to
reveal it (SC 2.4.7). It is display: none now — the route it carried is
in the collapsed tail as "Your sheets", and page.go redirects / to
/sheets for anyone signed in, so nothing is lost by it leaving the tab
order.

And the toolbar work moved to static/toolbar.js: commands.js reached 378
lines against the 300-line cap (guardstest/jscap_test.go). It is the
seam the Docs app already draws — commands.js decides what a control
does, this decides which one the keyboard is on and which are on screen.

Verified in Chromium against a real instance at 390px: the overflow and
a colour menu open together with the swatches visible and on screen; a
tap inside the panel leaves it open and a tap on a menu bar title closes
it; exactly one tab stop, visible, panel open and closed; Escape closes
and lands focus on the visible "More formatting" summary. The strip is
36px at 320, 390, 481, 560, 660, 900, 1000 and 1280 with no horizontal
scroll at any of them. Full Go suite and guardstest pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014jpLiN9xwBrZ5dJw92aYmF
Paul pushed by paul@keymail.dev fae05f5dd76d90ad312455c9fe68e30c19c5aa84 parent 0c1d565
4 files changed, +185 −47
  • internal/sheets/static/commands.js +0 −25
  • internal/sheets/static/sheets.css +19 −16
  • internal/sheets/static/toolbar.js +132 −0
  • internal/sheets/templates/sheet.html +34 −6

CI passed — run details

diff --git a/internal/sheets/static/commands.js b/internal/sheets/static/commands.js
index 6dfb90a..48c701c 100644
--- a/internal/sheets/static/commands.js
+++ b/internal/sheets/static/commands.js
@@ -264,31 +264,6 @@
el.setAttribute('data-set', '');
}
- /* ---- the toolbar is one tab stop ---------------------------------
- *
- * Fourteen buttons, fourteen tab stops, is a strip a keyboard user
- * has to walk through to reach the grid. Roving tabindex with arrow
- * keys is the convention for a toolbar, and matches what the menu
- * bar beside it does. */
- var strip = document.querySelector('[data-toolbar]');
- if (!strip) return;
- var stops = Array.prototype.slice.call(strip.querySelectorAll('[data-tool]'));
- if (!stops.length) return;
- stops.forEach(function (el, i) { el.tabIndex = i === 0 ? 0 : -1; });
-
- strip.addEventListener('keydown', function (e) {
- if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
- var i = stops.indexOf(e.target.closest('[data-tool]'));
- if (i < 0) return;
- e.preventDefault();
- /* Jspreadsheet listens for arrows on document and would walk the
- * cell cursor as well. */
- e.stopPropagation();
- var next = stops[(i + (e.key === 'ArrowRight' ? 1 : -1) + stops.length) % stops.length];
- stops.forEach(function (el) { el.tabIndex = -1; });
- next.tabIndex = 0;
- next.focus();
- });
}
if (window.SheetsGrid) ready();
diff --git a/internal/sheets/static/sheets.css b/internal/sheets/static/sheets.css
index d403b1e..f15a9bb 100644
--- a/internal/sheets/static/sheets.css
+++ b/internal/sheets/static/sheets.css
@@ -89,10 +89,23 @@
* condition — "there is already a title here" — rather than naming the
* page that happens to satisfy it today.
*
- * Clipped, not removed. [rst-shell-brand] is still the link home, and a
- * link with no accessible name is worse than one with a name nobody can
- * see. This is .rst-sr-only's own technique, written out because it has
- * to be undone again below.
+ * display: none, and not the 1px clip this started as. The clip was
+ * argued from "a link with no accessible name is worse than one nobody
+ * can see", which is true of a link that is still the only route
+ * somewhere — and this one is not. Below 800px "Your sheets" is in the
+ * collapsed tail with the same destination (page.go redirects / to
+ * /sheets for anyone signed in), so the clip bought a second
+ * announcement of a route already in the menu, and cost a focus stop one
+ * pixel square with nothing to reveal it: a keyboard user tabbing the bar
+ * landed on a control they could not see or locate. rastrillo's
+ * [rst-skip] gets away with the same technique because it un-clips on
+ * :focus-visible and is a real destination; this is neither, so it
+ * leaves the tab order (SC 2.4.7).
+ *
+ * `display: revert` and not a literal on the way back: tokens.css
+ * declares no display for this element, so the value to return to is the
+ * user agent's, and naming one here would freeze an answer that is not
+ * this file's to give.
*
* Written mobile-first and reverted inside rastrillo's own
* `min-width: 800px` — the width its topbar collapses the tail at — so
@@ -100,22 +113,12 @@
* rule would leave the fractional viewports that browser zoom really
* does produce uncovered by either. */
[rst-shell-bar]:has([data-doc-title]) [rst-shell-brand] {
- position: absolute;
- inline-size: 1px;
- block-size: 1px;
- overflow: hidden;
- clip-path: inset(50%);
- white-space: nowrap;
+ display: none;
}
@media (min-width: 800px) {
[rst-shell-bar]:has([data-doc-title]) [rst-shell-brand] {
- position: static;
- inline-size: auto;
- block-size: auto;
- overflow: visible;
- clip-path: none;
- white-space: normal;
+ display: revert;
}
}
diff --git a/internal/sheets/static/toolbar.js b/internal/sheets/static/toolbar.js
new file mode 100644
index 0000000..b33d4bb
--- /dev/null
+++ b/internal/sheets/static/toolbar.js
@@ -0,0 +1,132 @@
+/* The formatting toolbar's keyboard and overflow behaviour.
+ *
+ * Split out of commands.js, which was over the 300-line module cap
+ * (AGENTS.md Frontend, and internal/guardstest/jscap_test.go holds it).
+ * It is a real seam rather than a place the file happened to be cut:
+ * commands.js decides what every control DOES, and this decides which
+ * ones the keyboard is on and which ones are on screen. Nothing here
+ * touches the command table or the grid.
+ *
+ * The Docs app draws the same line in the same place
+ * (fixtures/toolbar-roving.mjs and toolbar-overflow.mjs), deliberately,
+ * so the two apps' toolbars stay one idea in two codebases.
+ */
+(function () {
+ 'use strict';
+
+ var surface = document.querySelector('[data-sheet-surface]');
+ if (!surface) return;
+
+ function ready() {
+ /* ---- the toolbar is one tab stop ---------------------------------
+ *
+ * Fourteen buttons, fourteen tab stops, is a strip a keyboard user
+ * has to walk through to reach the grid. Roving tabindex with arrow
+ * keys is the convention for a toolbar, and matches what the menu
+ * bar beside it does. */
+ var strip = document.querySelector('[data-toolbar]');
+ if (!strip) return;
+ var all = Array.prototype.slice.call(strip.querySelectorAll('[data-tool]'));
+ if (!all.length) return;
+
+ /* Only what a person can actually reach.
+ *
+ * Until the narrow-screen overflow landed, every control in this
+ * strip was on screen and this list was the whole of it. Below 30rem
+ * the colour menus and the three alignments are inside a panel that
+ * is display: none while the "..." is closed, and a hidden element
+ * cannot take focus — but it can still be handed tabIndex 0, which
+ * is what the arrow keys did: right-arrow from Italic assigned the
+ * stop to the hidden Fill colour summary and called .focus() on it,
+ * which silently did nothing. The stop was then parked somewhere
+ * unfocusable, so Tab skipped the toolbar altogether and further
+ * right-arrows retried the same hidden control.
+ *
+ * checkVisibility() is the direct question; offsetParent is the
+ * fallback and reads null for a display: none ancestor, which is the
+ * only way anything in this strip is hidden. */
+ function visible(el) {
+ return el.checkVisibility ? el.checkVisibility() : el.offsetParent !== null;
+ }
+ function reachable() { return all.filter(visible); }
+
+ /* Recomputed on every use rather than captured once: which controls
+ * are visible changes when the "..." opens and when the viewport
+ * crosses the breakpoint, and a list captured at load is a list of
+ * where the controls were, not where they are. */
+ function setStop(target) {
+ all.forEach(function (el) {
+ var want = el === target ? 0 : -1;
+ if (el.tabIndex !== want) el.tabIndex = want;
+ });
+ }
+ function ensureReachable() {
+ var list = reachable();
+ if (!list.length) return;
+ var current = null;
+ list.forEach(function (el) { if (el.tabIndex === 0) current = el; });
+ setStop(current || list[0]);
+ }
+ ensureReachable();
+
+ strip.addEventListener('keydown', function (e) {
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
+ var stops = reachable();
+ var i = stops.indexOf(e.target.closest('[data-tool]'));
+ if (i < 0) return;
+ e.preventDefault();
+ /* Jspreadsheet listens for arrows on document and would walk the
+ * cell cursor as well. */
+ e.stopPropagation();
+ var next = stops[(i + (e.key === 'ArrowRight' ? 1 : -1) + stops.length) % stops.length];
+ setStop(next);
+ next.focus();
+ });
+
+ /* ---- the "..." overflow ------------------------------------------
+ *
+ * Click-away and Escape, which <details> does neither of for itself
+ * and which rastrillo.js deliberately does not do for this one —
+ * templates/sheet.html's own comment explains why this control is
+ * kept out of rastrillo's MENUS, and it comes down to the panel being
+ * this element's SIBLING rather than its content, so every
+ * containment check rastrillo makes answers the wrong question about
+ * it.
+ *
+ * Containment is therefore tested against the whole strip, not
+ * against the <details>: a tap on a swatch inside the panel is a tap
+ * inside the toolbar and must leave it open, and a tap on a menu bar
+ * title or in the grid is outside and must close it.
+ *
+ * Escape hands focus back to the summary. A control inside the panel
+ * is inside a box that is about to be display: none, and focus left
+ * there is focus on something the browser has stopped rendering —
+ * the same hole rastrillo.js's own Escape handler is written to avoid
+ * for the menus it does own.
+ *
+ * Capture phase and no stopPropagation, matching rastrillo.js: this
+ * page has a grid, a formula bar and a live stream that all listen
+ * for clicks, and swallowing events on the way past would break
+ * them. */
+ var overflow = strip.querySelector('[data-tool-overflow]');
+ if (overflow) {
+ document.addEventListener('click', function (e) {
+ if (overflow.open && !strip.contains(e.target)) overflow.open = false;
+ }, true);
+ strip.addEventListener('keydown', function (e) {
+ if (e.key !== 'Escape' || !overflow.open) return;
+ overflow.open = false;
+ var summary = overflow.querySelector('summary');
+ if (summary) { setStop(summary); summary.focus(); }
+ e.preventDefault();
+ e.stopPropagation();
+ });
+ /* The stop may be sitting on a control the toggle just hid or
+ * revealed. */
+ overflow.addEventListener('toggle', ensureReachable);
+ }
+ }
+
+ if (window.SheetsGrid) ready();
+ else document.addEventListener('sheets:ready', ready, { once: true });
+}());
diff --git a/internal/sheets/templates/sheet.html b/internal/sheets/templates/sheet.html
index f42cf1e..b46c5a3 100644
--- a/internal/sheets/templates/sheet.html
+++ b/internal/sheets/templates/sheet.html
@@ -20,6 +20,7 @@
<script defer src="{{asset "static/cellqueue.js"}}"></script>
<script defer src="{{asset "static/clipboard.js"}}"></script>
<script defer src="{{asset "static/commands.js"}}"></script>
+<script defer src="{{asset "static/toolbar.js"}}"></script>
{{end}}
{{define "doctitle"}}
@@ -377,18 +378,45 @@
{{/* The "..." itself, at the trailing end and not rendered at all above
30rem.
- A <details rst-dropdown> like every other menu here, in the
- toolbar's own "rst-tools" exclusive group so that opening it closes
- the colour menus and vice versa — one panel at a time, which is
- what the group is for. rastrillo.js gives it click-away and Escape
- with nothing added.
+ A BARE <details>: no rst-dropdown, no name="rst-tools". Both were
+ here for one round and both were wrong, for the same underlying
+ reason — the panel this control reveals is its SIBLING
+ ([data-tool-extra] above), not its content, because a closed
+ <details> hides its own content and the wide layout cannot be
+ recovered from inside one. That is rastrillo's own idiom for the
+ shell tail and it is the right shape; what it means is that every
+ piece of machinery that assumes "the menu contains what it opened"
+ is wrong about this one:
+
+ - name="rst-tools" made it exclusive with the two colour menus,
+ which sit INSIDE the panel it reveals. Opening Fill colour
+ closed this disclosure, which hid the panel, which took the
+ colour menu down with it. Fill and text colour were simply
+ unreachable on a phone. It is the nested-group trap this file
+ already warns about at the top, met from the other side: not a
+ <details> inside a grouped <details>, but a grouped <details>
+ whose panel holds one.
+
+ - rst-dropdown put it in rastrillo.js's MENUS, whose click-away
+ closes any open menu that does not CONTAIN the click. Every tap
+ on a swatch or an alignment button is outside this element, so
+ the panel closed under the finger on every single use. Its
+ Escape handler has the same shape from the other end: focus on
+ an alignment button finds no menu ancestor, so it closes the
+ panel and leaves focus on an element that is no longer rendered.
+
+ So this control is invisible to rastrillo.js on purpose, and
+ static/commands.js gives it the click-away and the Escape that
+ actually fit the sibling shape. Its summary keeps [data-tool], so it
+ is the same 28px box as the buttons it stands in for and the roving
+ tabindex already walks it.
Three dots and not icons.go's "kebab": kebab is three STACKED dots
and its settled meaning across this design system is "more actions
on this row", which is not what this is. The accessible name is on
the summary because the glyph is a literal ellipsis with nothing to
announce. */}}
-<details rst-dropdown name="rst-tools" data-tool-overflow>
+<details data-tool-overflow>
<summary data-tool rst-tip aria-label="More formatting" data-tip="More formatting">&#8230;</summary>
</details>