mmis1000andClaude Opus 5.5 8bc104f2d0 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>
2026-10-13 07:21:35 +08:00
2026-10-12 23:27:56 +08:00
2026-10-12 19:10:03 +08:00

SofaNav

vibes coded

CI

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 (or edge://extensions) → Developer mode → Load unpacked → pick extension/. 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 install dist/sofanav.user.js. LB/RB do nothing there. Each build is stamped with a hash of content.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 @version whenever the code changed, so the manager offers it as an update.
  • Quick try on any page: paste dist/sofanav.user.js into 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_DEVIATION off 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 than MIN_WEDGE are 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.
  • ?timer drives polling with a timer instead of animation frames (for automated runs in a background tab).
  • The test pages set window.__sofanavDebug = true, which exposes window.__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.

Languages
JavaScript 74.3%
HTML 25.7%