diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 7e4723f7..96fea605 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -696,6 +696,34 @@ html.mobile-init .file-browser-panel { text-overflow: ellipsis; } + /* ⚠️ Reserve a tappable label on the ACTIVE tab, which is the only one that + grows action icons. With a short session name the icons were eating the + tab: "w1" rendered a 13px label while gear + close took 50px of a 116px + tab, so the tab's geometric CENTRE landed on the gear and a thumb aiming + at the tab opened Session Options instead of switching sessions (measured + at 360, 393 and 430px; only long names cleared it). The tab widens by the + difference instead, which costs a little strip space on exactly one tab + and keeps tap-to-switch the majority of it. + + ⚠️ The floor is set by the 10th tab onward, NOT by the numbered tabs you + are looking at. `.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. The centre clears the icons when + + reserved > icons + rightEdge - leftRunUp - gap + = 50 + 9 - 17 - 4 = 38px + + with icons = gear 32 + close 20 - close's -2px margin, leftRunUp = border 1 + + padding 8 + status dot 4 + gap 4, and rightEdge = padding 8 + border 1. + Hit testing snaps to whole pixels, so 39px still lands on the gear: the + practical floor is 40px and 44px keeps 4px of headroom. A NUMBERED tab + clears it at 20px, so reasoning from the tabs on screen is exactly what + would put the centre back on the gear. Pinned by + test/mobile-tab-tap-zones.test.ts. */ + .session-tab.active .tab-name { + min-width: 44px; + } + /* Hide close/gear buttons on non-active tabs on mobile */ .session-tab .tab-close, .session-tab .tab-gear { diff --git a/test/mobile-tab-tap-zones.test.ts b/test/mobile-tab-tap-zones.test.ts new file mode 100644 index 00000000..ab9926b4 --- /dev/null +++ b/test/mobile-tab-tap-zones.test.ts @@ -0,0 +1,170 @@ +/** + * @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: 430px)'; +/** `.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 { + const found: Record = {}; + 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 { + const found: Record = {}; + 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) => + (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: ` 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'); + }); +});