mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
Merge pull request #737: fix(mobile): show a press on the phone toolbar and accessory keys
This commit is contained in:
commit
7ed660ff11
4 files changed
+204
-1
No files matched your search
@@ -57,6 +57,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
@@ -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).
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user