From 8bc104f2d0d23d2a75c17e987f1df9ed16fe4657 Mon Sep 17 00:00:00 2001 From: mmis1000 Date: Tue, 13 Oct 2026 07:21:35 +0800 Subject: [PATCH] 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 --- dist/sofanav.user.js | 19 ++++++++++--------- docs/test-cases.md | 13 +++++++++++++ extension/content.js | 11 ++++++----- scripts/regressions.js | 13 ++++++++++--- test/index.html | 20 ++++++++++++-------- 5 files changed, 51 insertions(+), 25 deletions(-) diff --git a/dist/sofanav.user.js b/dist/sofanav.user.js index d3d8ce3..a30da03 100644 --- a/dist/sofanav.user.js +++ b/dist/sofanav.user.js @@ -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)}`); diff --git a/docs/test-cases.md b/docs/test-cases.md index 84447b7..300f928 100644 --- a/docs/test-cases.md +++ b/docs/test-cases.md @@ -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 diff --git a/extension/content.js b/extension/content.js index d5272b4..00f294a 100644 --- a/extension/content.js +++ b/extension/content.js @@ -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)}`); diff --git a/scripts/regressions.js b/scripts/regressions.js index 6ab8583..2dda257 100644 --- a/scripts/regressions.js +++ b/scripts/regressions.js @@ -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); diff --git a/test/index.html b/test/index.html index 9eac4c5..a05fa88 100644 --- a/test/index.html +++ b/test/index.html @@ -281,11 +281,12 @@ - -