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..1b05ab737 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -3473,6 +3473,47 @@ 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, + 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: transform 80ms ease, filter 80ms ease, background-color 80ms ease, border-color 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..d9ba90129 --- /dev/null +++ b/test/phone-press-feedback.browser.test.ts @@ -0,0 +1,161 @@ +/** + * @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)'; + +/** 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); + 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(); + 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); + 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, transition }; + } + + 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 }, + transition: PRESS_TRANSITION, + }); + } + } 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 }, + transition: PRESS_TRANSITION, + }); + } + } finally { + await context.close(); + } + }); +});