Files
Codeman/test/mobile-tab-tap-zones.test.ts
T
JD c087d0ae4d fix(mobile): raise the phone breakpoint from 430px to 600px
The phone tier stopped at innerWidth < 430 and @media (max-width: 430px), so every current large phone landed in the tablet layout: the 430pt iPhone 14 Pro Max, 15 Plus, 15 Pro Max and 16 Plus, the 440pt iPhone 16 Pro Max and 17 Pro Max, Pixel 6 Pro, 7 Pro and OnePlus 12 Pro, the 448pt Pixel 8 Pro and 9 Pro XL, and the Galaxy Z Fold 5 cover screen at 460. On those devices the header icon row replaced the session pill, the toolbar kept the desktop Run Shell button instead of Enter and the mic, the keyboard accessory bar could never become visible because its .visible rule lives inside the phone block, and the toolbar jumped to the top of the page when the keyboard opened.

The new cutoff is 600, the line test/mobile/devices.ts already draws between large phones (430-599) and small tablets (600-767). No physical device sits between 480 and 600, but a phone zoomed out one or two steps in Safari does: a 440pt iPhone at 85% or 75% page zoom reports 518px or 587px and still needs the phone controls, which a 480 cutoff would have taken away. The phone block is max-width: 599px and the tablet block starts at min-width: 600px, so a 600px device is a tablet in CSS and in getDeviceType() alike instead of straddling the boundary the way 430pt phones did.

The number changes everywhere it is encoded: JS, CSS, comments, CLAUDE.md, the CI tests that pin the phone block, and the test:mobile helpers. Measurement history that names 430px stays as written.
2026-09-08 00:56:39 -04:00

171 lines
7.5 KiB
TypeScript

/**
* @fileoverview A phone tab's tap-to-switch area must stay bigger than its
* action icons.
*
* The active tab is the only one that grows a gear and a close button, and on a
* phone they were eating it: with a short session name ("w1", "api") the label
* rendered 13-16px wide while gear + close took 50px of a 116px tab, so the
* tab's geometric CENTRE landed on the gear. Aiming a thumb at the middle of
* the tab opened Session Options instead of switching sessions, measured at
* 360, 393 and 430px.
*
* `min-width` on the active tab's name is what fixes it, and this guard pins the
* arithmetic behind the number rather than the number itself.
*
* ⚠️ The governing case is the 10th tab onward, NOT the tabs you can see.
* `.tab-number` is rendered only for `_tabIdx < 9` (app.js), so tab 10 loses
* 16px + a 4px gap off its left and its centre sits 10px further right. Measured
* in Chromium at 393px: a NUMBERED tab clears the gear once the label reserves
* 20px, a numberless one needs 40px. Reasoning from the tabs on screen is
* exactly what would put the centre back on the gear.
*
* The centre sits left of the icons when
*
* reserved > icons + rightEdge - leftRunUp - gap
*
* which is what `requiredReserve()` below recomputes from the stylesheet, so
* widening the gear or the padding fails here instead of on someone's phone.
* Note this is "off the icons", not "inside the label": on a numberless tab the
* centre lands in the 4px gap between the name and the icons, which still
* switches sessions, because the click handler is on the tab and gear/close
* both `stopPropagation()`.
*
* Parsed with postcss instead of a regex because the declarations live in a
* nested `@media` block. Behaviour (the tap itself, the gear still opening
* options, tablets unaffected) is covered live in a browser; this file is the
* cheap regression fence. Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import postcss, { type Declaration, type Rule } from 'postcss';
import { describe, expect, it } from 'vitest';
const CSS = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile.css'), 'utf8');
const ROOT = postcss.parse(CSS);
/** The phone block. Tablets keep the roomier layout and are deliberately out of scope. */
const PHONE_QUERY = '(max-width: 599px)';
/** `.session-tab` border, from styles.css: `border: 1px solid transparent`. */
const TAB_BORDER = 1;
/**
* Hit testing snaps to whole pixels, so a centre half a pixel left of the icons
* still reports as the gear (measured: 39px reserved -> centre 59.5, icons at
* 60.0, `elementFromPoint` returned `.tab-gear`). One pixel is the rounding, the
* second is deliberate slack.
*/
const ROUNDING_ALLOWANCE = 2;
/** Declarations for a selector inside the phone media block, later rules winning. */
function phoneDeclarations(selector: string): Record<string, string> {
const found: Record<string, string> = {};
ROOT.walkAtRules('media', (atRule) => {
if (atRule.params !== PHONE_QUERY) return;
atRule.walkRules((rule: Rule) => {
const selectors = rule.selectors.map((s) => s.trim());
if (!selectors.includes(selector)) return;
rule.walkDecls(record(found));
});
});
return found;
}
/** Declarations for a selector anywhere at or above the phone block (e.g. the <=768px one). */
function mobileDeclarations(selector: string): Record<string, string> {
const found: Record<string, string> = {};
ROOT.walkRules((rule: Rule) => {
if (!rule.selectors.map((s) => s.trim()).includes(selector)) return;
rule.walkDecls(record(found));
});
return found;
}
/**
* postcss lifts `!important` off the value onto `decl.important`, and here it is
* load-bearing rather than noise (it is what beats the hover/detached reveal
* rules in styles.css), so put it back where a test can assert it.
*/
const record =
(into: Record<string, string>) =>
(decl: Declaration): void => {
into[decl.prop] = decl.value.trim() + (decl.important ? ' !important' : '');
};
/** px value of a single CSS length. `rem` resolves against the untouched 16px root. */
const len = (value: string | undefined): number => {
if (!value) return NaN;
const m = /^(-?[\d.]+)(px|rem)?$/.exec(value.trim());
if (!m) return NaN;
return Number.parseFloat(m[1]!) * (m[2] === 'rem' ? 16 : 1);
};
/** Horizontal component of a `padding: <v> <h>` shorthand. */
const paddingX = (shorthand: string | undefined): number => {
const parts = shorthand?.trim().split(/\s+/) ?? [];
return len(parts.length >= 2 ? parts[1] : parts[0]);
};
describe('phone tab tap zones', () => {
const name = phoneDeclarations('.session-tab.active .tab-name');
const nameShared = phoneDeclarations('.session-tab .tab-name');
const tab = phoneDeclarations('.session-tab');
const status = phoneDeclarations('.session-tab .tab-status');
const gear = phoneDeclarations('.session-tab.active .tab-gear');
const close = phoneDeclarations('.session-tab.active .tab-close');
const reserved = len(name['min-width']);
const gap = len(tab.gap);
const padX = paddingX(tab.padding);
/** gear + close, less the negative margin that overlaps them. */
const icons = len(gear.width) + len(close.width) + len(close['margin-left']);
/**
* The worst case: tab 10+, which renders no `.tab-number`, so the left run-up
* is border + padding + status dot + one gap.
*/
function requiredReserve(): number {
const leftRunUp = TAB_BORDER + padX + len(status.width) + gap;
const rightEdge = padX + TAB_BORDER;
return icons + rightEdge - leftRunUp - gap;
}
it('reads every term the arithmetic depends on', () => {
// A typo'd selector would silently make every threshold below NaN, and NaN
// comparisons are always false, so a broken parse must fail loudly here.
for (const [label, value] of Object.entries({ reserved, gap, padX, icons, status: len(status.width) })) {
expect(value, `${label} did not parse`).toBeGreaterThan(0);
}
});
it('reserves enough label that a numberless tab centres off its action icons', () => {
// This is the whole point, and the 10th tab is the one that decides it.
expect(reserved).toBeGreaterThanOrEqual(requiredReserve() + ROUNDING_ALLOWANCE);
});
it('keeps the measured practical floor', () => {
// Belt to the braces above: 40px is where a numberless tab was measured to
// stop hit-testing onto the gear at 360/393/430px.
expect(reserved).toBeGreaterThanOrEqual(40);
});
it('the reserved width still fits inside the truncation cap', () => {
// A min-width above the max-width would stretch every tab to the reserved
// size and silently undo the aggressive phone truncation.
expect(reserved).toBeLessThanOrEqual(len(nameShared['max-width']));
});
it('the icons stay tappable in their own right', () => {
// Shrinking the icons is the other way to win this argument, and it trades
// one mis-tap for another. They must not get smaller than this.
expect(len(gear.width)).toBeGreaterThanOrEqual(28);
expect(len(close.width)).toBeGreaterThanOrEqual(18);
});
it('icons stay hidden on non-active tabs, so those are tappable end to end', () => {
expect(phoneDeclarations('.session-tab .tab-gear').display).toBe('none');
});
it('the pop-out icon stays out of the cluster on handhelds', () => {
// `icons` above counts gear + close only. If detach ever became visible on a
// phone the reserve would be ~30px short and the centre would walk back.
expect(mobileDeclarations('.session-tab .tab-detach').display).toBe('none !important');
});
});