From bf17cd9dbd20bd48afaa95c46a76424565242f06 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Wed, 21 Oct 2026 17:50:57 -0300 Subject: [PATCH] fix(controller): the D-pad's focus stays visible after a mouse click The controller moves the focus by script, and once the mouse has clicked anywhere Chromium stops drawing :focus-visible for that. On the keyboard the D-pad then moved from key to key with none of them lit, so it looked as if the pad no longer reached it. The shell now marks data-input="pad" while the pad is in use (any button or stick past the deadzone) and drops it on a click or a physical key. Every :focus-visible rule of the shell and the design tokens has a twin for :focus under that mark, so whatever the pad focuses is drawn: the keys, the suggestions, the toolbar, the tabs and the menus. Co-Authored-By: Claude Opus 5.5 --- app/src/assets/design/tokens.css | 6 +++++- app/src/renderer/shell/gamepad.ts | 12 ++++++++++- app/src/renderer/shell/shell.css | 36 +++++++++++++++++++++---------- app/tests/e2e/controller.spec.ts | 15 +++++++++++++ 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/app/src/assets/design/tokens.css b/app/src/assets/design/tokens.css index 63cd747..4ae6099 100644 --- a/app/src/assets/design/tokens.css +++ b/app/src/assets/design/tokens.css @@ -74,7 +74,11 @@ button { cursor: pointer; } :focus { outline: none; } -:focus-visible { +/* The controller moves the focus by script, and after any mouse click Chromium stops drawing :focus-visible for that: + the D-pad then walked the keyboard with nothing lit. While the pad is in use (data-input, set by gamepad.ts) every + focus is drawn. A dialog that takes the focus only to be announced (tabindex -1) gets no ring. */ +:focus-visible, +:root[data-input="pad"] :focus:not([tabindex="-1"]) { outline: var(--focus-ring) solid var(--accent); outline-offset: calc(2 * var(--u)); } diff --git a/app/src/renderer/shell/gamepad.ts b/app/src/renderer/shell/gamepad.ts index e7d42b0..f6ee5ac 100644 --- a/app/src/renderer/shell/gamepad.ts +++ b/app/src/renderer/shell/gamepad.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from "preact/hooks"; import { controllerFamily, type ControllerFamily } from "../../shared/controllerGlyphs"; -import { Button, DOUBLE_PRESS_MS, directionOf, stickToScroll, stickToVelocity, type Direction } from "../../shared/controllerInput"; +import { Button, DOUBLE_PRESS_MS, STICK_DEADZONE, directionOf, stickToScroll, stickToVelocity, type Direction } from "../../shared/controllerInput"; import { activePad, padsInUse, startedUsing, type PadSample } from "../../shared/controllerSelection"; import { GAMEPAD_POLL_MS, gamepadPoll, STICK_INTERVAL_MS } from "../../shared/gamepadLoop"; import type { ShellState } from "../../shared/ipc"; @@ -106,6 +106,10 @@ export function useGamepad(state: ShellState, overlay: Overlay, setOverlay: (o: bridge().controller({ type: "connected", connected: true, family: family.current }); } + // The pad is what is driving: its focus moves must be drawn (tokens.css), whatever the mouse did before. + if (pad.buttons.some((b) => b.pressed) || pad.axes.some((axis) => Math.abs(axis) > STICK_DEADZONE)) { + document.documentElement.dataset.input = "pad"; + } const s = stateRef.current; const openOverlay = overlayRef.current; const overlayOpen = openOverlay !== "none" || Boolean(s.keyboard) || Boolean(s.permission); @@ -217,6 +221,10 @@ export function useGamepad(state: ShellState, overlay: Overlay, setOverlay: (o: } // Up from the top of the page: the main process hands the focus over and the address field takes it. bridge().onFocusChrome(() => document.getElementById("address")?.focus()); + // A click or a key on a real keyboard hands focus drawing back to the browser's own rules. + const notPad = (): void => { delete document.documentElement.dataset.input; }; + addEventListener("pointerdown", notPad, true); + addEventListener("keydown", notPad, true); resume(); const visibility = (): void => { if (!document.hidden) resume(); @@ -228,6 +236,8 @@ export function useGamepad(state: ShellState, overlay: Overlay, setOverlay: (o: }); return () => { stop(); + removeEventListener("pointerdown", notPad, true); + removeEventListener("keydown", notPad, true); document.removeEventListener("visibilitychange", visibility); stopActivity(); }; diff --git a/app/src/renderer/shell/shell.css b/app/src/renderer/shell/shell.css index f18c6d2..ed38dcd 100644 --- a/app/src/renderer/shell/shell.css +++ b/app/src/renderer/shell/shell.css @@ -53,7 +53,8 @@ body { } .strip-btn:hover, -.strip-btn:focus-visible { background: var(--hover); color: var(--text); } +.strip-btn:focus-visible, +:root[data-input="pad"] .strip-btn:focus { background: var(--hover); color: var(--text); } .strip-btn .badge { position: absolute; @@ -177,12 +178,17 @@ body { } .tab-x:hover, -.tab-x:focus-visible { background: var(--hover); color: var(--text); } +.tab-x:focus-visible, +:root[data-input="pad"] .tab-x:focus { background: var(--hover); color: var(--text); } .tab-main:focus-visible, .tab-x:focus-visible, .new-tab:focus-visible, -.strip-btn:focus-visible { outline-offset: calc(-2 * var(--u)); } +.strip-btn:focus-visible, +:root[data-input="pad"] .tab-main:focus, +:root[data-input="pad"] .tab-x:focus, +:root[data-input="pad"] .new-tab:focus, +:root[data-input="pad"] .strip-btn:focus { outline-offset: calc(-2 * var(--u)); } .new-tab { flex: none; @@ -217,7 +223,8 @@ body { .window-btn:active { background: var(--border); } .window-btn.close:hover { background: #c42b1c; color: #fff; } .window-btn.close:active { background: #c42b1ccc; color: #fff; } -.window-btn:focus-visible { outline-offset: calc(-3 * var(--u)); } +.window-btn:focus-visible, +:root[data-input="pad"] .window-btn:focus { outline-offset: calc(-3 * var(--u)); } /* ---- Toolbar ---- */ @@ -244,7 +251,8 @@ body { } .icon-btn:hover, -.icon-btn:focus-visible { background: var(--hover); } +.icon-btn:focus-visible, +:root[data-input="pad"] .icon-btn:focus { background: var(--hover); } .icon-btn.on { color: var(--accent); } .address { @@ -288,7 +296,8 @@ body { } .address-btn:hover, -.address-btn:focus-visible { background: var(--hover); color: var(--text); } +.address-btn:focus-visible, +:root[data-input="pad"] .address-btn:focus { background: var(--hover); color: var(--text); } .address-btn.on { color: var(--accent); } .tab-hint { @@ -389,7 +398,8 @@ body { } .menu-item:hover, -.menu-item:focus-visible { background: var(--hover); } +.menu-item:focus-visible, +:root[data-input="pad"] .menu-item:focus { background: var(--hover); } /* ---- Tab switcher ---- */ @@ -498,8 +508,10 @@ body { text-align: left; } -.chip:focus-visible { background: var(--text); color: var(--bg); outline: none; } -.chip:focus-visible .chip-url { color: inherit; } +.chip:focus-visible, +:root[data-input="pad"] .chip:focus { background: var(--text); color: var(--bg); outline: none; } +.chip:focus-visible .chip-url, +:root[data-input="pad"] .chip:focus .chip-url { color: inherit; } .chip-title { font-size: var(--text-sm); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .chip-url { font-size: var(--text-xs); color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -528,7 +540,8 @@ body { .key:hover { background: var(--hover); } /* The selected key turns white, as on Xbox: the one thing on the panel the eye finds from the sofa. */ -.key:focus-visible { +.key:focus-visible, +:root[data-input="pad"] .key:focus { z-index: 1; background: var(--text); color: var(--bg); @@ -538,7 +551,8 @@ body { .key.on { background: var(--accent); color: var(--accent-text); } .key.primary { background: var(--accent); color: var(--accent-text); } -.key.primary:focus-visible { background: var(--text); color: var(--bg); } +.key.primary:focus-visible, +:root[data-input="pad"] .key.primary:focus { background: var(--text); color: var(--bg); } .key-label { font-size: inherit; line-height: 1; } .key-gap { pointer-events: none; } diff --git a/app/tests/e2e/controller.spec.ts b/app/tests/e2e/controller.spec.ts index 373acd0..392204d 100644 --- a/app/tests/e2e/controller.spec.ts +++ b/app/tests/e2e/controller.spec.ts @@ -373,3 +373,18 @@ test("the pointer is on every page from the start, and stays where it was when t await expect.poll(pointerShown, { timeout: 10_000 }).toBe(true); await expect.poll(where).toBe(first); }); + +test("after a mouse click, the key the D-pad reaches is still lit", async () => { + await installPads([XBOX]); + // A mouse click first: from then on Chromium stops drawing :focus-visible for focus moved by script. + await chrome.locator(".new-tab").click(); + await expect(chrome.locator("#keyboard")).toBeVisible(); + await step(Pad.Right); + await expect.poll(shellFocus).toBe("q"); + // The selected key turns the text colour (white on the dark themes), not the grey of the other keys. + const lit = () => chrome.evaluate(() => getComputedStyle(document.activeElement as Element).backgroundColor === getComputedStyle(document.body).color); + await expect.poll(lit).toBe(true); + await step(Pad.Right); + await expect.poll(shellFocus).toBe("w"); + await expect.poll(lit).toBe(true); +});