Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/test-suites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

/**
Expand Down
2 changes: 1 addition & 1 deletion src/web/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
41 changes: 41 additions & 0 deletions src/web/public/mobile.css
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
161 changes: 161 additions & 0 deletions test/phone-press-feedback.browser.test.ts
Original file line number Diff line number Diff line change
@@ -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('<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();
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();
}
});
});
Loading