B sends Escape to the focused element, wherever it is (E31)

User's debug report from x.com: X's full-screen mask was detected, but
it's a sibling of the menu, and Escape was sent from the mask, so X's
handler on the menu never saw it. Escape now always goes to the focused
element like a real key (X focuses the menu when it opens). Test page:
mask beside the menu, menu off the overlay sample points, B pressed
with SofaNav on the account button; fails on 29e2788.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mmis1000andClaude Opus 5.5 committed 2026-10-13 07:21:35 +08:00
1 parent 29e2788cfa
commit 8bc104f2d0
5 files changed
+51 -25

No files matched your search

+10 -9
View File
@@ -1,8 +1,8 @@
// ==UserScript==
// @name SofaNav
// @namespace https://sofanav.mmis1000.me
// @version 0.3.0.202610121845
// @description Navigate web pages with a controller: D-pad / left stick move focus, A click, B back, X enter, Y graph view. (build 971c074a2345)
// @version 0.3.0.202610122320
// @description Navigate web pages with a controller: D-pad / left stick move focus, A click, B back, X enter, Y graph view. (build fff84d1e8ea6)
// @homepageURL https://mmis1000.me/SofaNav/
// @downloadURL https://mmis1000.me/SofaNav/sofanav.user.js
// @updateURL https://mmis1000.me/SofaNav/sofanav.user.js
@@ -16,7 +16,7 @@
// @inject-into content
// ==/UserScript==
// Generated from extension/content.js by build-userscript.js. Edit that file, not this one.
// Build 971c074a2345 = first 12 hex digits of sha256(extension/content.js).
// Build fff84d1e8ea6 = first 12 hex digits of sha256(extension/content.js).
// LB/RB (switch tabs) needs the extension; in a userscript they do nothing.
// @sandbox DOM / @inject-into content run SofaNav in an isolated world, where the page can't
// reach it. Only then does it control cross-origin frames (over GM storage, see content.js
@@ -34,7 +34,7 @@
// parent and carries out commands; the top frame does all navigation.
const IS_FRAME = window.top !== window;
// Replaced with the content hash by build-userscript.js; 'dev' when loaded unbuilt (extension, test pages).
const BUILD = '971c074a2345';
const BUILD = 'fff84d1e8ea6';
const USERSCRIPT = true; // the userscript build sets this
if (!IS_FRAME) console.info('[sofanav] build', BUILD);
@@ -210,13 +210,14 @@
}
// Something is open over the page (aria-modal dialog, floating menu, covering layer): one
// Escape attempt per B press. It goes where a real key would: the focused element (handlers
// sit on it or above it; events only bubble up, so sending from a wrapper around it, such as
// a scroll box, misses them). If nothing inside has focus: SofaNav's element, or the first
// focusable thing in it. Resolves true if it closed.
// Escape attempt per B press. It goes where a real key would: the focused element, wherever
// it is (handlers sit on it or above it; events only bubble up). `box` may not contain it:
// X's mask is a sibling of its menu, and X focuses the menu. Only with nothing focused:
// SofaNav's element (or the first focusable thing in it) if it's inside, else the box.
// Resolves true if the box closed.
async function dismissOpen(box, trigger) {
const ae = document.activeElement, cur = nodeOf(current);
let target = ae && ae !== document.body && box.contains(ae) ? ae : null;
let target = ae && ae !== document.body && ae !== document.documentElement ? ae : null;
if (!target && cur instanceof Element && box.contains(cur)) target = cur.querySelector(FOCUSABLE_SEL) || cur;
sendEscape(target || box);
backNote(`Escape sent from ${brief(target || box)}`);
+13
View File
@@ -457,6 +457,19 @@ usual (leave scope / go back), since the hint is weaker than markup or a mask.
Light popups are now closed before leaving a scope inside them. The test page's
X-style menu copies X's real structure; fails on 883b834 and acd22c7.
*Still failing on X (user's debug report with the new `lastBack`/`open`
fields, build 971c074a2345):* on the user's (larger) screen X's transparent
full-screen mask *was* detected (`overlay`), but it's a sibling of the menu,
not around it; SofaNav's element was the account button and X's focus was on
the first menu item, so Escape went out from the mask, outside the menu, and
X's handler never saw it. On a smaller screen the menu covered one of the
overlay check's sample points, so only the floating-box path ran, which is
why my checks passed. *Fix:* Escape always goes to the focused element, like a
real key, whatever box was detected; only with nothing focused does it use
SofaNav's element or the box. The test page now adds a mask beside the menu,
keeps the menu off the sample points, and presses B with SofaNav on the
account button (the report's state); fails on 29e2788.
**E32. Mouse mode: an escape hatch for what focus navigation can't reach**
(requested by the user after E31). Even with E31, some UI can't be handled by
moving focus: popups that only close on an outside click, controls that only
+6 -5
View File
@@ -186,13 +186,14 @@
}
// Something is open over the page (aria-modal dialog, floating menu, covering layer): one
// Escape attempt per B press. It goes where a real key would: the focused element (handlers
// sit on it or above it; events only bubble up, so sending from a wrapper around it, such as
// a scroll box, misses them). If nothing inside has focus: SofaNav's element, or the first
// focusable thing in it. Resolves true if it closed.
// Escape attempt per B press. It goes where a real key would: the focused element, wherever
// it is (handlers sit on it or above it; events only bubble up). `box` may not contain it:
// X's mask is a sibling of its menu, and X focuses the menu. Only with nothing focused:
// SofaNav's element (or the first focusable thing in it) if it's inside, else the box.
// Resolves true if the box closed.
async function dismissOpen(box, trigger) {
const ae = document.activeElement, cur = nodeOf(current);
let target = ae && ae !== document.body && box.contains(ae) ? ae : null;
let target = ae && ae !== document.body && ae !== document.documentElement ? ae : null;
if (!target && cur instanceof Element && box.contains(cur)) target = cur.querySelector(FOCUSABLE_SEL) || cur;
sendEscape(target || box);
backNote(`Escape sent from ${brief(target || box)}`);
+10 -3
View File
@@ -77,8 +77,15 @@ const checks = {
}, [btn, sel]);
await press('ArrowUp');
await page.evaluate(() => { location.hash = 'before-menu'; });
// As on X: from the menu's scroll box, X then ↓ lands on the role="group" (as in the user's
// debug report); one B must close the menu.
// As in the user's report from x.com: SofaNav still on the account button, X's focus on the
// first menu item, a mask beside the menu. One B must close it.
await page.evaluate(() => { const b = document.getElementById('menuBtn'); b.scrollIntoView({ block: 'center' }); __sofanav.focus(b); b.click(); });
await wait(300);
await press('Backspace', 700);
const fromButton = await page.evaluate(() => ({ open: !document.getElementById('xMenu').hidden, hash: location.hash }));
if (fromButton.hash !== '#before-menu') return { ok: false, detail: `B on the account button went back a page (menu open=${fromButton.open})` };
if (fromButton.open) return { ok: false, detail: 'X-style menu still open after one B on the account button' };
// From the menu's scroll box, X then ↓ lands on the role="group" (the earlier report).
await open('menuBtn', '#xScroll');
await wait(300);
await press('KeyX');
@@ -116,7 +123,7 @@ const checks = {
await wait(300);
await press('KeyF');
r.on = await page.evaluate(() => !!__sofanav.mouse());
const blank = await page.evaluate(() => { for (let y = 60; y < innerHeight; y += 20) for (let x = innerWidth - 20; x > innerWidth / 2; x -= 40) { const e = document.elementFromPoint(x, y); if (e === document.body || e === document.documentElement) return [x, y]; } return null; });
const blank = await page.evaluate(() => { for (let y = 60; y < innerHeight; y += 20) for (let x = innerWidth - 20; x > innerWidth / 2; x -= 40) { const e = document.elementFromPoint(x, y); if (e === document.body || e === document.documentElement || e.id === 'xMask') return [x, y]; } return null; });
await mouseTo(...blank);
await press('Enter');
r.menuClosed = await page.evaluate(() => document.getElementById('xMenu').hidden);
+12 -8
View File
@@ -281,11 +281,12 @@
<button type="button" id="stuckBtn">Overlay that ignores Escape</button>
<button type="button" id="popBtn" popovertarget="pop">Popover menu</button>
</div>
<!-- Like X's real account menu (checked on x.com): a small position:fixed box, no mask, no
menu/dialog markup, the button isn't marked expanded; a role="group" holds a scroll box
(a SofaNav container) of links, and Escape is handled on the group, so it must be sent
from inside it. -->
<div id="xMenu" hidden style="position:fixed;left:40px;bottom:80px;width:300px;z-index:50;background:#2a2a2a;border:1px solid #555;border-radius:8px">
<!-- Like X's real account menu (checked on x.com): a small position:fixed box, no menu/dialog
markup, the button isn't marked expanded; a role="group" holds a scroll box (a SofaNav
container) of links, and Escape is handled on the group, so it must come from inside it.
A transparent full-screen mask sits *beside* the menu (not around it) and closes it on click. -->
<div id="xMask" hidden style="position:fixed;inset:0;z-index:49"></div>
<div id="xMenu" hidden style="position:fixed;left:16px;bottom:80px;width:150px;z-index:50;background:#2a2a2a;border:1px solid #555;border-radius:8px">
<div role="group" tabindex="0" id="xGroup">
<div id="xScroll" style="max-height:90px;overflow-y:auto;padding:8px">
<div><a href="#add" role="menuitem">Add an existing account</a></div>
@@ -332,10 +333,13 @@
});
document.getElementById('openDialog').onclick = () => document.getElementById('dlg').showModal();
const xMenu = document.getElementById('xMenu'), menuLayer = document.getElementById('menuLayer');
document.getElementById('menuBtn').onclick = () => { xMenu.hidden = false; document.querySelector('#xScroll a').focus(); };
document.getElementById('xGroup').addEventListener('keydown', (e) => { if (e.key === 'Escape') xMenu.hidden = true; });
const xMask = document.getElementById('xMask');
const setX = (open) => { xMenu.hidden = xMask.hidden = !open; };
document.getElementById('menuBtn').onclick = () => { setX(true); document.querySelector('#xScroll a').focus(); };
document.getElementById('xGroup').addEventListener('keydown', (e) => { if (e.key === 'Escape') setX(false); });
xMask.addEventListener('click', () => setX(false));
// Like X: a press outside the menu closes it.
document.addEventListener('pointerdown', (e) => { if (!xMenu.hidden && !xMenu.contains(e.target) && e.target.id !== 'menuBtn') xMenu.hidden = true; });
document.addEventListener('pointerdown', (e) => { if (!xMenu.hidden && !xMenu.contains(e.target) && e.target.id !== 'menuBtn') setX(false); });
const hoverCard = document.getElementById('hoverCard'), hoverTools = document.getElementById('hoverTools');
hoverCard.addEventListener('mouseenter', () => { hoverTools.style.display = 'block'; });
hoverCard.addEventListener('mouseleave', () => { hoverTools.style.display = 'none'; });