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>
12 KiB
SofaNav
Website, live demo and one-click install: https://mmis1000.me/SofaNav/
Console-style controller navigation for any web page. The D-pad and left stick move a focus ring between links and buttons by their position on screen (like an Xbox/TV app), not a mouse cursor.
Ships as a browser extension (Chrome, Edge, Firefox) and as a userscript built from the same source.
Layout
extension/ the extension; content.js is the single source for everything
manifest.json MV3, runs in all frames
background.js LB/RB tab switching (extension only)
content.js all navigation logic (engine in the top frame, client in iframes)
build-userscript.js builds dist/sofanav.user.js from extension/content.js
scripts/record-demo.js records demo/sofanav-demo.mp4 with Playwright
scripts/regressions.js automated regression checks (npm run test:regress)
scripts/build-site.js assembles the website into _site/ (npm run site)
site/ the website / landing page (runs SofaNav itself)
.github/workflows/ CI: build check + regression checks, then deploys the website
dist/ generated userscript (do not edit)
test/
index.html main test page, one section per behaviour / past bug
frame.html page loaded into the test page's iframes
stress.html Twitter-sized page for performance checks
docs/
test-cases.md what to check, and every edge case / bug found so far
Install
- Extension:
chrome://extensions(oredge://extensions) → Developer mode → Load unpacked → pickextension/. Firefox:about:debugging→ Load Temporary Add-on →extension/manifest.json. - Userscript: install from the website (https://mmis1000.me/SofaNav/sofanav.user.js;
Tampermonkey/Violentmonkey pick it up and keep it updated), or build it yourself:
npm run build, then installdist/sofanav.user.js. LB/RB do nothing there. Each build is stamped with a hash ofcontent.js(shown in the description and a// Build <hash>comment under the header, logged as[sofanav] build …in the console, and in the View on/off toast) and gets a newer@versionwhenever the code changed, so the manager offers it as an update. - Quick try on any page: paste
dist/sofanav.user.jsinto the DevTools console.
Controls
| Input | Action |
|---|---|
| D-pad | Move between elements (4-direction scored graph) |
| Left stick | Free aim at any angle (ray cast) |
| A | Click / focus a text field |
| B | Leave text field → leave container → close dialog / popover → Escape to whatever covers the page (menus, overlays) → otherwise history back |
| Hold B | Always history back (if a popup won't close) |
| L3 (click left stick) | Mouse mode on/off: left stick moves a cursor, D-pad nudges it, A clicks, right stick / LT / RT scroll under it, hover events are sent. The escape hatch for anything focus navigation can't reach |
| X | Enter the focused container (a post card, or a scrollable panel — A also enters a panel) |
| Y | Cycle graph view: off → focused item → whole screen |
| LB / RB | Previous / next tab (extension only) |
| LT / RT | Page up / down |
| Right stick | Scroll (a focused scrollable panel scrolls itself, without entering) |
| View | Turn navigation on/off |
| Menu (☰) | Debug report: log the focused element, how it's classified, and where each D-pad direction goes and why (graph / rescued / return / fallback) |
| Alt+Shift+G (keyboard) | Same as Y |
| Alt+Shift+D (keyboard) | Same as Menu (some sites swallow this shortcut; use Menu there) |
How it works (short)
- Candidates: focusable elements within one screen of the viewport, that are visible, not covered, and reachable by scrolling (not inside a closed drawer).
- D-pad: score = distance across the gap between boxes × (1/cos(angle off
direction))^
ANGLE_PENALTY, rejecting anything >MAX_DEVIATIONoff or merely beside. Then a graph pass rescues elements nothing points to. - Stick: a ray from the focused element's center; first box hit wins; misses
take the angularly nearest element within
AIM_TOLERANCE; wedges narrower thanMIN_WEDGEare merged. The pie in the graph view is this exact table. - "Go back": right after a move, pushing the reverse direction returns to where you came from. In the graph view these arrows are dotted; rescued ones are dashed.
- Containers: focusable elements with focusable contents, and scrollable panels holding focusable items, are one stop; X (or A for panels) enters (only its contents exist), B leaves. Scopes stack. Scrollable areas with nothing focusable inside (e.g. a live chat) are stops the right stick scrolls. All of this works inside iframes too.
- Frames: the top frame is the engine; each iframe runs the same script as a
client that reports its elements and executes commands. With the extension
they talk only through extension messaging (pages can't forge it); as a
userscript, through the manager's GM storage when it runs isolated
(Tampermonkey
@sandbox DOM, Violentmonkey@inject-into content), otherwise only same-origin frames take part. Frame elements become proxies with translated rects, so all logic treats them alike. A frame whose page scrolls is a container (one stop, enter with A/X); frames that don't scroll are flattened into the page. - Recovery: if the focused element vanishes or is scrolled away, focus lands on the nearest visible element in the same scroll area.
Tunable constants are at the top of extension/content.js (stick thresholds,
repeat timing, scoring, wedge sizes); each has a comment.
Testing
npm run serve
Automated checks (Playwright, server running): npm run test:regress, and
npm run test:security (loads the real extension; forged-command attack).
Then open http://localhost:8765/test/ (the cross-origin frame loads from
127.0.0.1, so use localhost in the address). See docs/test-cases.md.
- No controller? Keyboard fallback on the test page: arrows = D-pad, Enter = A, Backspace = B, X, Y, PgUp/PgDn = LT/RT, V = View, Q/E/Z/C = stick at 45°, J/L rotate aim + K = push stick at aim.
?timerdrives polling with a timer instead of animation frames (for automated runs in a background tab).- The test pages set
window.__sofanavDebug = true, which exposeswindow.__sofanav(candidates(),buildGraph(),current(),scopes(),focus(el),state(), ...) for inspection and scripted tests.
Demo video
demo/sofanav-demo.mp4 (≈60 s): D-pad, graph view, stick pie, post cards, a scrollable comments panel, scrolling iframes as containers, and a non-scrolling iframe whose buttons belong to the page.
To re-record (after changes), with the test server running:
npm install
npx playwright install chromium
npm run demo
scripts/record-demo.js drives the test page with Playwright through the
keyboard stand-in and the virtual stick, adds captions and an input badge, and
writes demo/sofanav-demo.webm plus an .mp4 if ffmpeg is installed.
VERBOSE=1 npm run demo logs where focus lands after every press.
Website and CI
site/index.html is the landing page at https://mmis1000.me/SofaNav/. It
runs the built script itself, so visitors can try SofaNav with a controller (or
an opt-in keyboard mode). npm run build && npm run site assembles _site/:
the page, sofanav.user.js (install/update URL), sofanav-extension.zip,
the demo video and its poster. Preview locally with npm run serve:site.
.github/workflows/ci.yml runs on every push and pull request: it checks that
dist/sofanav.user.js was rebuilt from the current content.js, runs the
regression checks in Chromium, builds the site, and on main deploys it to
GitHub Pages.