From 8d75e6bd15c53a31f4ea887f43917348634127bb Mon Sep 17 00:00:00 2001 From: JD Date: Wed, 21 Oct 2026 21:09:57 -0400 Subject: [PATCH 1/2] fix(mobile): show a press on the phone toolbar and accessory keys On every skin but og the skin block outranks the phone :active backgrounds of the toolbar buttons, so Run showed no press at all and the others at most a faint tint, eased in over their 0.2s transition, so a quick tap showed almost nothing. A press now scales and brightens (darkens on the light skins) over 80ms, two properties nothing on these buttons sets at a higher specificity, and honours prefers-reduced-motion. A browser test presses every phone bar control on all seven skins and pins the computed transform and filter, so a later rule that takes the press away again shows up in the browser suite. --- config/test-suites.ts | 1 + src/web/CLAUDE.md | 2 +- src/web/public/mobile.css | 40 ++++++ test/phone-press-feedback.browser.test.ts | 149 ++++++++++++++++++++++ 4 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 test/phone-press-feedback.browser.test.ts diff --git a/config/test-suites.ts b/config/test-suites.ts index c403421d4..657ee73a5 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -56,6 +56,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/markdown-anchor-links.browser.test.ts', 'test/fullscreen-tab-switch-capture.browser.test.ts', 'test/terminal-touch-swipe-axis.browser.test.ts', + 'test/phone-press-feedback.browser.test.ts', ]; /** diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index e2c9cd1ed..508ceaecb 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -84,7 +84,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L ⚠️ **`sendEnterKey()` MUST go through `terminal._core.coreService.triggerDataEvent('\r', true)`** — not `sendInput()`, and never a raw POST to `/api/sessions/:id/input`. `localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`, so on every phone the characters you type are buffered in the `LocalEchoOverlay` and have **never reached the PTY**; the `onData` Enter branch in terminal-ui.js is what flushes `pendingText` first and only then sends `\r` (after an 80ms delay so text lands first). Sending a bare `\r` submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. `KeyboardAccessory.sendKey()` is for escape sequences (arrows/Esc) and is the WRONG template to copy for input. -⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important` — that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey. +⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important` — that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey. Press feedback on the phone bars sidesteps this: the `:active` rules for `.btn-toolbar`, the mobile icon buttons and `.accessory-btn` (mobile.css) use `transform` and `filter` only, which nothing on these buttons sets at a higher specificity, so one rule reads the same on every skin; a background `:active` lost to the skin block on all but og, and an inset `box-shadow` lost to the skin hover rules, which is why neither is used. The light skins darken instead of brightening, and `prefers-reduced-motion` drops the scale. **Connection-loss UI** (`computeConnectionLossUi()` in constants.js, writer `_updateConnectionLossUi()` in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen **overlay** while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking **banner** once it has (the terminal scrollback stays readable). ⚠️ A **2.5s grace** is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. `navigator.onLine === false` skips the grace, since that is never a blip. Retry re-arms SSE **and** the terminal WS (`planWsReconnect` can 'give-up', and the SSE backoff caps at 30s). diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index f4f56b1e0..d3285339f 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -3473,6 +3473,46 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat .subagent-dropdown-trigger:focus + .subagent-dropdown-menu { display: block; } + + /* Press feedback for the touch bars. The pressed look is transform + filter, + never a background or a shadow: the skin block (html:not([data-skin="og"]), + specificity 0,2,1) and the per-run-mode colors own the background and its + hover rules own box-shadow, so a background or shadow :active lost to them + on every non-og skin. Nothing on these buttons sets transform or filter + (the base .btn-toolbar:active nudge sits at lower specificity), so one rule + reads the same on all of them. 80ms so a quick tap shows the press at all. */ + .toolbar .btn-toolbar, + .btn-voice-mobile, + .btn-attach-mobile, + .btn-settings-mobile, + .accessory-btn { + -webkit-tap-highlight-color: transparent; + transition: all 80ms ease; + } + + .toolbar .btn-toolbar:active, + .btn-voice-mobile:active, + .btn-attach-mobile-wrap:active .btn-attach-mobile, + .btn-settings-mobile:active, + .accessory-btn:active { + transform: scale(0.94); + filter: brightness(1.25); + } + + /* Light skins darken on press instead of brightening (white on white reads as nothing). */ + html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.toolbar .btn-toolbar:active, .btn-voice-mobile:active, .btn-attach-mobile-wrap:active .btn-attach-mobile, .btn-settings-mobile:active, .accessory-btn:active) { + filter: brightness(0.9); + } +} + +@media (hover: none) and (pointer: coarse) and (prefers-reduced-motion: reduce) { + .toolbar .btn-toolbar:active, + .btn-voice-mobile:active, + .btn-attach-mobile-wrap:active .btn-attach-mobile, + .btn-settings-mobile:active, + .accessory-btn:active { + transform: none; + } } @media (max-width: 599px) { diff --git a/test/phone-press-feedback.browser.test.ts b/test/phone-press-feedback.browser.test.ts new file mode 100644 index 000000000..53ce81b43 --- /dev/null +++ b/test/phone-press-feedback.browser.test.ts @@ -0,0 +1,149 @@ +/** + * @fileoverview Real-browser coverage for the press feedback on the phone toolbar + * and keyboard accessory keys (mobile.css, the `:active` transform + filter rules). + * + * The feedback is transform + filter because a background `:active` on the + * toolbar buttons lost to the skin block on every skin but og. What this pins is + * the cascade itself, on every skin: a later rule that sets transform or filter + * on these buttons at a higher specificity would silently take the press away + * again, and only a browser computing the cascade can see that. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; + +const publicDir = resolve(import.meta.dirname, '../src/web/public'); +const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8'); +const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8'); +const mobileStyles = readFileSync(resolve(publicDir, 'mobile.css'), 'utf8'); +const accessorySource = readFileSync(resolve(publicDir, 'keyboard-accessory.js'), 'utf8'); + +const DARK_SKINS = ['og', 'daylight-blue', 'daylight-green'] as const; +const LIGHT_SKINS = ['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn'] as const; + +/** Every phone bar control the rules cover, pressed where a finger would land. */ +const TARGETS = [ + ['Run', '.toolbar .btn-toolbar.btn-run'], + ['Run gear', '.toolbar .btn-toolbar.btn-run-gear'], + ['Stop', '.toolbar .btn-toolbar.btn-stop'], + ['Enter', '.toolbar .btn-toolbar.btn-enter'], + ['Case', '.toolbar .btn-toolbar.btn-case-mobile'], + ['Voice', '.btn-voice-mobile'], + // The pick is a transparent file input laid over this button, so the press + // lands on the input and only the wrap is :active. + ['Attach', '.btn-attach-mobile'], + ['Settings', '.btn-settings-mobile'], + ['Accessory Tab', '.accessory-btn[data-action="tab"]'], + ['Accessory arrow', '.accessory-btn-arrow[data-action="scroll-up"]'], + ['Accessory Compose', '.accessory-btn-compose'], + ['Accessory dismiss', '.accessory-btn-dismiss'], +] as const; + +const PRESSED_SCALE = 'matrix(0.94, 0, 0, 0.94, 0, 0)'; + +function toolbarMarkup(): string { + const start = html.indexOf('', start); + if (start < 0 || end < 0) throw new Error('Toolbar markup not found'); + return html.slice(start, end + ''.length); +} + +function accessoryMarkup(): string { + const match = accessorySource.match(/_simpleButtons: `([\s\S]*?)`,/); + if (!match) throw new Error('Accessory bar template not found'); + return `
${match[1]}
`; +} + +describe('phone toolbar and accessory press feedback in Chromium', () => { + let browser: Browser; + + beforeAll(async () => { + browser = await chromium.launch({ headless: true }); + }); + + afterAll(async () => browser.close()); + + async function openBars(skin: string, reducedMotion: 'reduce' | 'no-preference'): Promise<[BrowserContext, Page]> { + // hasTouch makes Chromium match (hover: none) and (pointer: coarse), the gate the rules sit behind. + const context = await browser.newContext({ + viewport: { width: 440, height: 956 }, + isMobile: true, + hasTouch: true, + deviceScaleFactor: 1, + reducedMotion, + }); + const page = await context.newPage(); + await page.setContent(` + + + ${toolbarMarkup()}${accessoryMarkup()}`); + // Without the viewport meta a mobile context lays out at 980px and never reaches the phone layout. + expect( + await page.evaluate(() => ({ + touch: matchMedia('(hover: none) and (pointer: coarse)').matches, + phone: matchMedia('(max-width: 599px)').matches, + })) + ).toEqual({ touch: true, phone: true }); + return [context, page]; + } + + /** Hold a press on the control and read its transform and filter once the transition has settled. */ + async function pressed(page: Page, selector: string) { + const target = page.locator(selector).first(); + const box = await target.boundingBox(); + if (!box) throw new Error(`${selector} is not rendered in the phone layout`); + const read = () => + target.evaluate((el) => ({ transform: getComputedStyle(el).transform, filter: getComputedStyle(el).filter })); + const rest = await read(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.waitForTimeout(250); + const held = await read(); + // Release off the control so its click handler never runs. + await page.mouse.move(0, 0); + await page.mouse.up(); + await page.waitForTimeout(250); + return { rest, held }; + } + + for (const [skins, brightness] of [ + [DARK_SKINS, 'brightness(1.25)'], + [LIGHT_SKINS, 'brightness(0.9)'], + ] as const) { + for (const skin of skins) { + it(`scales every control and applies ${brightness} on the ${skin} skin`, async () => { + const [context, page] = await openBars(skin, 'no-preference'); + try { + for (const [name, selector] of TARGETS) { + expect({ name, ...(await pressed(page, selector)) }).toEqual({ + name, + rest: { transform: 'none', filter: 'none' }, + held: { transform: PRESSED_SCALE, filter: brightness }, + }); + } + } finally { + await context.close(); + } + }); + } + } + + it.each([ + ['daylight-blue', 'brightness(1.25)'], + ['paper-gray', 'brightness(0.9)'], + ])('drops the scale but keeps the tint under prefers-reduced-motion on %s', async (skin, brightness) => { + const [context, page] = await openBars(skin, 'reduce'); + try { + for (const [name, selector] of TARGETS) { + expect({ name, ...(await pressed(page, selector)) }).toEqual({ + name, + rest: { transform: 'none', filter: 'none' }, + held: { transform: 'none', filter: brightness }, + }); + } + } finally { + await context.close(); + } + }); +}); From f91cd343bee2bb1a82af93f0b58c436c64e4e929 Mon Sep 17 00:00:00 2001 From: JD Date: Wed, 28 Oct 2026 13:39:13 -0400 Subject: [PATCH 2/2] fix(mobile): keep the press transition to the press's own properties `all` also sped up Run's mode colours, Stop's confirming state and the armed Ctrl key. The browser test now reads the transition at rest, since its 250ms hold would also pass the old 0.2s one. --- src/web/public/mobile.css | 5 +++-- test/phone-press-feedback.browser.test.ts | 14 +++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index d3285339f..1b05ab737 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -3480,14 +3480,15 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat hover rules own box-shadow, so a background or shadow :active lost to them on every non-og skin. Nothing on these buttons sets transform or filter (the base .btn-toolbar:active nudge sits at lower specificity), so one rule - reads the same on all of them. 80ms so a quick tap shows the press at all. */ + reads the same on all of them. 80ms so a quick tap shows the press at all, + on the press's own properties rather than `all`. */ .toolbar .btn-toolbar, .btn-voice-mobile, .btn-attach-mobile, .btn-settings-mobile, .accessory-btn { -webkit-tap-highlight-color: transparent; - transition: all 80ms ease; + transition: transform 80ms ease, filter 80ms ease, background-color 80ms ease, border-color 80ms ease; } .toolbar .btn-toolbar:active, diff --git a/test/phone-press-feedback.browser.test.ts b/test/phone-press-feedback.browser.test.ts index 53ce81b43..d9ba90129 100644 --- a/test/phone-press-feedback.browser.test.ts +++ b/test/phone-press-feedback.browser.test.ts @@ -42,6 +42,12 @@ const TARGETS = [ const PRESSED_SCALE = 'matrix(0.94, 0, 0, 0.94, 0, 0)'; +/** The press transition, read at rest: the 250ms hold below would also pass a slower one. */ +const PRESS_TRANSITION = { + property: 'transform, filter, background-color, border-color', + duration: '0.08s, 0.08s, 0.08s, 0.08s', +}; + function toolbarMarkup(): string { const start = html.indexOf('', start); @@ -96,6 +102,10 @@ describe('phone toolbar and accessory press feedback in Chromium', () => { const read = () => target.evaluate((el) => ({ transform: getComputedStyle(el).transform, filter: getComputedStyle(el).filter })); const rest = await read(); + const transition = await target.evaluate((el) => ({ + property: getComputedStyle(el).transitionProperty, + duration: getComputedStyle(el).transitionDuration, + })); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); await page.waitForTimeout(250); @@ -104,7 +114,7 @@ describe('phone toolbar and accessory press feedback in Chromium', () => { await page.mouse.move(0, 0); await page.mouse.up(); await page.waitForTimeout(250); - return { rest, held }; + return { rest, held, transition }; } for (const [skins, brightness] of [ @@ -120,6 +130,7 @@ describe('phone toolbar and accessory press feedback in Chromium', () => { name, rest: { transform: 'none', filter: 'none' }, held: { transform: PRESSED_SCALE, filter: brightness }, + transition: PRESS_TRANSITION, }); } } finally { @@ -140,6 +151,7 @@ describe('phone toolbar and accessory press feedback in Chromium', () => { name, rest: { transform: 'none', filter: 'none' }, held: { transform: 'none', filter: brightness }, + transition: PRESS_TRANSITION, }); } } finally {