X's menu stays unchanged for ~300 ms after Escape (closing animation).
B checked once at 250 ms, saw it still open, and on the floating-box
path went back a page as well. Now it polls for up to 1 s. Test page:
the X-style menu closes after 300 ms, and one case covers an overlay
sample point so only the floating-box path sees it; fails on 8bc104f.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
The report now shows the focused element, what B's checks find right
now (popup / covering overlay / floating box), and the last B press
step by step (detections, where Escape was sent, closed or not, errors).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
L3 toggles a cursor: left stick moves it, D-pad nudges, A clicks at the
point, right stick / LT / RT scroll under it, moves send hover events.
Works in frames through the trusted frame channel (pointer command,
fromTop mapping). Leaving focuses the nearest stop. Regression covers
outside-click close, a hover-only control, a frame click, nudge, stick.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Checked on x.com: the menu is a small fixed box with no mask and no
popup markup; a scripted Escape closes it only when sent from inside it.
Escape now goes to the focused element (like a real key), light popups
close before leaving a scope inside them, and a new weak hint (focus in
a fixed box touching no screen edge, under half the screen) gets one
Escape before B carries on as usual. Test fixture copies X's structure.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The markup-based popup fix missed X's account menu (a role="group" on a
full-screen mask). B now: closes dialogs/popovers with the browser APIs;
if anything looks open (aria-modal, popup markup, or a fixed layer that
covers the page and hides focusable content outside it) sends one
Escape (legacy keyCode/which set) and does not go back; otherwise goes
back as before. Holding B 700 ms always goes back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
X's account menu is a floating role="menu", not a dialog, so B went
back a page and left it open. B now finds an open popup (floating
menu/listbox/non-modal dialog, open popover, or what an expanded
aria-haspopup button controls) and closes it: Escape from inside the
popup, then hidePopover() or pressing the menu button again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to E29. When the userscript runs in an isolated world
(Tampermonkey @sandbox DOM, Violentmonkey @inject-into content, checked
via GM_info), frame commands travel through the manager's per-script
storage (GM_setValue + GM_addValueChangeListener on random per-instance
keys), which pages can't write. Without isolation it stays same-origin
only: in page mode the attacker page can fake navigator.getGamepads for
SofaNav in its own top frame (shown by a negative control).
Also: activate() passes document.defaultView as the event view, since
userscript managers may wrap window (PointerEvent threw in VM page mode).
test:security now installs the userscript in a pinned, sha256-checked
Violentmonkey and adds a fake-controller attack.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
External audit (ChatGPT, commit 7650661): the frame client trusted
postMessage from its parent, but a page that embeds another site is its
parent. A malicious page could fetch element ids and send "activate",
clicking inside the embedded site with no user input (its cookies and
CSRF token included). Reproduced with the real extension and as a page
script: an invisible victim frame's form was submitted 22 times.
- Extension: commands and replies go through extension messaging, relayed
by the background script with browser-provided tab/frame ids; it only
forwards commands from the tab's top frame and replies to it.
postMessage only carries a random single-use probe nonce that maps an
<iframe> to its frame id; forged probes are ignored.
- Userscript / page script: no unforgeable channel exists, so a frame is a
client only when its parent is same-origin; cross-origin frames are one
plain element.
- scripts/security.js (npm run test:security, also in CI): runs the attack
in both setups with the real extension loaded, and checks the extension
still reaches cross-origin frames (negative control: fails without the
relay). Fails 2 of 3 on 7650661, passes now.
- Test pages: ?ext mode (rely on the installed extension), attack pages.
- Docs, README, website and demo updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHub Pages for this account is served from the custom domain
mmis1000.me, so github.io only redirects. Update/download URLs in the
userscript, README links and og:image now point at https://mmis1000.me/SofaNav/.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- site/index.html: landing page that runs SofaNav itself (controller, or an
opt-in keyboard mode), demo video with poster, features, install links,
controls. scripts/build-site.js assembles _site/ (page, userscript,
extension zip, video) with no extra dependencies.
- .github/workflows/ci.yml: checks dist/ is rebuilt from content.js, runs
the Playwright regression checks, builds the site and deploys it to
GitHub Pages on main.
- Userscript header: @homepageURL/@downloadURL/@updateURL on the Pages site
so installs update themselves.
- One copy per page: the guard also marks <html data-sofanav>, so the
page's copy and an installed extension don't both run.
- E28: the button hint hides while its element is scrolled off screen
(it was clamped onto the screen next to nothing). New regression check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Package, extension and userscript are SofaNav / sofanav: dist/sofanav.user.js,
console prefix [sofanav], View toast, demo/sofanav-demo.mp4.
- Globally unique ids on mmis1000.me: userscript @namespace
https://sofanav.mmis1000.me, Firefox id sofanav@mmis1000.me, frame message
tag sofanav.mmis1000.me/1.
- Internal names: page-load guard __sofanavLoaded, debug hooks
__sofanavDebug / __sofanav, SVG marker ids sofanav-arrow-*.
- Userscript managers treat the new @name/@namespace as a new script:
remove the old "Gamepad Spatial Navigation" once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Twitter, up from a feed post went to a left-nav link whose corner was
13 px across / 14 px up from the post's corner: the box-gap score saw a
short 45° step and beat the reply box right above. When boxes don't overlap
sideways, their centers must now also be within 60° of the pressed
direction. Explains the earlier "two up targets" report (layout shift
changed which sidebar icon sat at the corner). Reachability and existing
paths on the test page unchanged; new regression check (fails before).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An element on Twitter showed two different "up" targets at different times.
Each target now carries why it was chosen (graph, rescued, return = go back
the way you came, fallback). The Menu debug report lists all four with
reasons plus lastMove; the graph view draws go-back arrows dotted (rescued
ones stay dashed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@build isn't a userscript key, so linters warned about it. The hash stays
in @description and a plain "// Build <hash>" comment after the block,
which the build also reads to keep the version stable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Twitter (from the Menu debug report): A on a post's back button went to
/home and focus stayed on the home timeline div, which was a small empty
stop while loading and then grew into a page-sized wrapper. Recovery now
waits for the DOM to settle (300 ms quiet, max 1.5 s) when focus vanished
mid-change, and a focused element whose area changes >3x is re-checked and
re-anchored (from its focus-time position) if it's no longer a stop.
New test-page section and regression check (fails on the previous code).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Alt+Shift+D didn't reach the script on Twitter; Menu (☰), previously
unused, now writes the same debug report. The test page maps M to Menu.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For a stop taller than the screen (Twitter's full-height sidebar) the hint
was placed above the top edge. It now goes below, else above, else inside
the element along its visible bottom edge, and is always clamped to the
screen. New regression check (fails on the previous code: hint at y=-45).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Twitter after a page change, focus could stay on the timeline column (a
stop while loading, not after), and moves from it skipped every post inside
it. A move now only starts from current if it is one of the current stops;
otherwise focus re-anchors to the nearest stop first. Alt+Shift+D logs the
focused element's classification for bug reports. New regression check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
So the build hash matches on every checkout regardless of local line
endings (git stores LF; files written on Windows may have CRLF).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- E22: frames run the same container/scope logic as the top page and report
container counts, scroll room and native clickability; the top frame
enters/leaves frame containers by message and scrolls them past their
edge. Frames judge "page-sized" against the top window's area, so a chat
list filling its frame is a container. Scrolling boxes with nothing
focusable (>= 120x60) are scroll-only stops for the right stick.
test/chat-frame.html reproduces YouTube live chat (real chat doesn't render
in automated browsers); two new regression checks.
- E23: userscript builds are stamped with a hash of content.js (@build,
description, console "[webpad] build <hash>", View toast) and a monotonic
@version 0.3.0.<UTC timestamp> that only changes with the code, so
userscript managers see updates.
- Version 0.3.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- E19: D-pad inside an entered scrolling frame whose viewport is blank no
longer scrolls the outer page. New scrollWithin() handles "nothing to move
to" for both the no-target and the empty re-entry case: scroll the scope if
it continues, the page only if the scope runs off screen, else bump.
- E20: B closes native <dialog> modals via requestClose() (fallback: cancel
event + close()); synthetic Escape is untrusted and was ignored. Synthetic
Escape kept for ARIA dialogs.
- E21: a frame's state resets on its load event (old proxies marked dead),
and after two consecutive unanswered snapshots, so a frame that navigates
to a page without the script becomes a plain element.
Adds scripts/regressions.js (npm run test:regress), which fails all three on
4bc57f8 and passes now; test page gets a sparse frame and a swappable frame.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
flatFrame loads frame.html?short (no tall section), so it fits and doesn't
scroll. It keeps the flattened case covered now that scrolling frames are
containers: its buttons navigate as page elements with no stop or scope.
Check 28 now uses it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A frame whose page scrolls is now one stop like any scrollable panel: the
right stick / LT / RT scroll it while focused, A/X enters, past the last
visible item the frame scrolls, B leaves. Frames report their document
scroll state with each snapshot and accept a scrollStep command.
Non-scrolling frames stay flattened; frames without the script stay one
plain element.
Docs: E18 and checks 25-28b; demo iframe scene re-recorded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- scripts/record-demo.js drives the test page (keyboard stand-in + virtual
sticks) through D-pad, graph view, stick pie, cards, a scroll panel and
iframes, with captions and an input badge; `npm run demo`
- demo/webpad-demo.mp4 (~50 s)
- Debug hook: __gpn.focus(el) and __gpn.state() for scripted runs
- test/frame.html forwards key events to the test page: with focus inside a
frame the keyboard stand-in lost key-ups and a virtual button stuck pressed
- Docs: demo section in README, new harness pitfalls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Console-style controller navigation for web pages:
- D-pad: scored 4-direction graph with orphan rescue
- Left stick: free-aim ray casting with a pie-chart visualization
- Keyboard-style repeat, stick dead/release zones and bounce filtering
- Focus recovery and scroll-area-aware re-entry
- Iframe support (top-frame engine, per-frame clients)
- Container scopes (X enters a card, B leaves); scrollable panels are
containers too and can be scrolled directly while focused
- Test pages and docs/test-cases.md (checklist + edge-case log E1-E17)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>