31 Commits
Author SHA1 Message Date
mmis1000andClaude Opus 5.5 5b94fde008 B waits for a popup to finish closing before going on (E31)
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>
2026-10-13 07:34:04 +08:00
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
mmis1000andClaude Opus 5.5 29e2788cfa Debug report: what B saw and did (lastBack), and open-state detection
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>
2026-10-13 02:47:20 +08:00
mmis1000andClaude Opus 5.5 b3b408dd3c Mouse mode on L3: a cursor as the escape hatch (E32)
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>
2026-10-13 02:26:33 +08:00
mmis1000andClaude Opus 5.5 e6953543fd B closes X's account menu: Escape from the focused element; floating-box hint (E31)
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>
2026-10-13 01:49:53 +08:00
mmis1000andClaude Opus 5.5 acd22c7d05 B: Escape for anything that looks open, never back over it; hold B goes back (E31)
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>
2026-10-13 01:24:38 +08:00
mmis1000andClaude Opus 5.5 883b83475a B closes floating menus and popovers before going back (E31)
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>
2026-10-13 01:07:52 +08:00
mmis1000andClaude Opus 5.5 36b24f846d SECURITY: userscript reaches cross-origin frames again, over GM storage (E30)
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>
2026-10-13 00:47:29 +08:00
mmis1000andClaude Opus 5.5 956d34e90c SECURITY: frames only take commands over a channel pages can't forge (E29)
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>
2026-10-13 00:26:27 +08:00
mmis1000andClaude Opus 5.5 7650661dd8 CI: move actions to versions that run on Node 24
checkout/setup-node v4 run on the deprecated Node 20 runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:52:42 +08:00
mmis1000andClaude Opus 5.5 b735dc3990 Use the site's real address (mmis1000.me/SofaNav)
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>
2026-10-12 23:49:00 +08:00
mmis1000andClaude Opus 5.5 a249ca23a4 Site build: empty _site/ instead of deleting it
On Windows a folder that is being served can't be removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:48:45 +08:00
mmis1000andClaude Opus 5.5 90cfdab12f Website (GitHub Pages) and CI; hide the hint when its element is off screen
- 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>
2026-10-12 23:48:14 +08:00
mmis1000andClaude Opus 5.5 7b0424c8a8 README: add vibes-coded badge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:37:27 +08:00
mmis1000andClaude Opus 5.5 6e9fea90fd Rename project to SofaNav
- 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>
2026-10-12 23:27:56 +08:00
mmis1000andClaude Opus 5.5 ef92df67f3 D-pad: sideways neighbours with a near-touching corner aren't "ahead" (E27)
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>
2026-10-12 23:17:40 +08:00
mmis1000andClaude Opus 5.5 2d2527fcee Explain D-pad targets: reason per direction in debug report and graph
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>
2026-10-12 23:08:32 +08:00
mmis1000andClaude Opus 5.5 6dfe62c6d4 Userscript: move the build hash out of the metadata block
@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>
2026-10-12 23:01:12 +08:00
mmis1000andClaude Opus 5.5 ab816406b8 Don't leave focus on an SPA page's loading placeholder (E26)
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>
2026-10-12 22:59:56 +08:00
mmis1000andClaude Opus 5.5 3f6cf83217 Debug report on the controller's Menu button
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>
2026-10-12 22:52:50 +08:00
mmis1000andClaude Opus 5.5 55f8ae0084 Keep the button hint on screen (E25)
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>
2026-10-12 22:48:07 +08:00
mmis1000andClaude Opus 5.5 f31bba6f44 Re-anchor when focus isn't a stop; Alt+Shift+D debug report (E24)
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>
2026-10-12 22:43:36 +08:00
mmis1000andClaude Opus 5.5 2175ab4833 Build: hash content.js with LF line endings
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>
2026-10-12 21:36:39 +08:00
mmis1000andClaude Opus 5.5 1ceb4c807c Containers inside frames (live chat), scroll-only stops, build hash
- 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>
2026-10-12 21:36:15 +08:00
mmis1000andClaude Opus 5.5 164c97eda1 Fix three bugs from external review (E19-E21) and add regression checks
- 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>
2026-10-12 21:19:49 +08:00
mmis1000andClaude Opus 5.5 4bc57f8b81 Demo video: show scrolling frames as containers and the flat frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:43:30 +08:00
mmis1000andClaude Opus 5.5 32c4f50948 Test page: add a non-scrolling iframe
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>
2026-10-12 19:41:36 +08:00
mmis1000andClaude Opus 5.5 426d6ab881 Treat scrolling iframes as scroll containers
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>
2026-10-12 19:38:49 +08:00
mmis1000andClaude Opus 5.5 cf0a2d3ba3 Add demo video and Playwright recording script
- 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>
2026-10-12 19:26:42 +08:00
mmis1000andClaude Opus 5.5 3f3b48dd5d Normalize line endings to LF
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:10:03 +08:00
mmis1000andClaude Opus 5.5 b1408448e5 Initial commit: gamepad spatial navigation (extension + userscript)
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>
2026-10-12 19:09:57 +08:00