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.
This commit is contained in:
JD committed 2026-10-28 01:04:15 -04:00
1 parent ecf28fa3be
commit 8d75e6bd15
4 files changed
+191 -1

No files matched your search

+1
View File
@@ -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',
];
/**
+1 -1
View File
@@ -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).
+40
View File
@@ -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) {
+149
View File
@@ -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('<footer class="toolbar">');
const end = html.indexOf('</footer>', start);
if (start < 0 || end < 0) throw new Error('Toolbar markup not found');
return html.slice(start, end + '</footer>'.length);
}
function accessoryMarkup(): string {
const match = accessorySource.match(/_simpleButtons: `([\s\S]*?)`,/);
if (!match) throw new Error('Accessory bar template not found');
return `<div class="keyboard-accessory-bar visible">${match[1]}</div>`;
}
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(`<!doctype html><html data-skin="${skin}"><head>
<meta name="viewport" content="width=device-width, initial-scale=1"><style>${styles}</style>
<style>@media (max-width: 1023px) { ${mobileStyles} }</style></head>
<body>${toolbarMarkup()}${accessoryMarkup()}</body></html>`);
// 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();
}
});
});