From 8389423459b321109579ac0de799b7d9ba300aa4 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 12 Sep 2026 05:25:49 +0200 Subject: [PATCH 1/5] feat(mobile): iPhone Duo support (fold-aware dialogs, no phantom keyboard) Apple's "Designing for iPhone Duo" asks an app to adapt to both displays, to stay continuous as the device opens and closes, and to treat the band a partly-open display folds through as a reserved region. Three things here. 1. A visual-viewport resize that changes the WIDTH is the device changing shape (a rotation, or a foldable opening or closing) and is never the virtual keyboard, which only ever takes height. handleViewportResize() read any height drop over 150px as the keyboard appearing, so closing a Duo (890 to 678pt tall) latched keyboardVisible with no keyboard on screen: the accessory bar appeared, main grew 84px of dead padding, and updateAppHeight() stopped refreshing --app-height. The latch was sticky, because clearing it needs the height back within 100px of a baseline belonging to a display the user is no longer looking at. Rotating any phone hit the same latch. The shape branch re-baselines instead, which is also what lets a keyboard opened after the fold be detected. 2. The hinge is now a reserved region in CSS. --fold-inline-end and --fold-block-end measure the strip to keep clear from the Viewport Segments env() variables, and are 0px everywhere else, so the seven centred overlays are inert by construction off a foldable. Each shrinks its content box with padding rather than the box itself, so the backdrop still covers the far side of the fold and still swallows taps there. 3. iPhone Duo (outer) and iPhone Duo (inner) join the mobile device registry, derived from Apple's published pixel specs at 3x. Verified in Chromium: flat, a dialog stays centred at 313 of a 626pt viewport; in book pose it centres at 153 inside the 0-305 leading segment with its right edge at 293, while the backdrop still spans all 626. The 3-term calc on the offline overlay resolves to 367px in tabletop pose and 20px flat. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 + src/web/public/mobile-handlers.js | 48 +++++- src/web/public/styles.css | 103 +++++++++++++ test/foldable-layout.test.ts | 234 +++++++++++++++++++++++++++++ test/mobile/devices.ts | 22 +++ test/mobile/settings.test.ts | 40 +++++ test/viewport-shape-change.test.ts | 216 ++++++++++++++++++++++++++ 7 files changed, 658 insertions(+), 7 deletions(-) create mode 100644 test/foldable-layout.test.ts create mode 100644 test/viewport-shape-change.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 437235640..345037457 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -326,6 +326,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Foldable settings identity**: responsive layout is width-driven via `MobileDetection.getDeviceType()`, but the localStorage namespace uses `MobileDetection.isHandheldDevice()` so an unfolded Android foldable keeps `codeman-app-settings-mobile`. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. → [architecture-invariants#foldable-settings-identity](docs/architecture-invariants.md#foldable-settings-identity) +**Folding devices: a fold is not a keyboard, and dialogs avoid the hinge** (Apple's [Designing for iPhone Duo](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo)): ⚠️ **A visual-viewport resize that changes the WIDTH is the device changing shape** (a rotation, or a foldable opening or closing) **and is never the virtual keyboard**, which only ever takes height. `handleViewportResize()` read any height drop over 150px as the keyboard appearing, so closing an iPhone Duo (626→466pt wide, 890→678pt tall) latched `keyboardVisible` with no keyboard on screen: the accessory bar appeared, `main` grew 84px of dead padding, and `updateAppHeight()` (which bails while the keyboard is up) stopped refreshing `--app-height`. The latch is STICKY, since clearing it needs the height back within 100px of a baseline belonging to a display the user is no longer looking at, so it survived until the device was opened again, and rotating any phone hit it too. The shape branch re-baselines instead, which is also what lets a keyboard opened AFTER the fold be detected. ⚠️ `init()` must seed `lastViewportWidth`, or the very first resize reads as a shape change and swallows a real keyboard. ⚠️ **The hinge is a reserved region.** The CSS Viewport Segments media features report two segments only while a foldable is actually bent, and `--fold-inline-end`/`--fold-block-end` (styles.css) measure the strip to keep clear: `0px` on everything else, so the rules are inert by construction rather than by a branch. Codeman's seven centred overlays are all `position: fixed; inset: 0` flex boxes, and each shrinks its CONTENT box with padding rather than the box itself, so the backdrop still covers the far side of the fold and still swallows taps there. ⚠️ Each rule RE-STATES the overlay's own gutter (a later `padding-right` longhand beats the earlier `padding` shorthand it composes with), and the palette needs the compound `.modal.command-palette-modal` because mobile.css loads later and pads it with a shorthand under 768px. `test/foldable-layout.test.ts` DERIVES the overlay list from the stylesheet and compares both numbers, so a new overlay or a moved gutter fails there instead of on hardware nobody has. ⚠️ Physical sides, not logical ones: dialogs sit in the LEFT segment (the TOP one in tabletop pose) in every language, because the HIG keeps Duo's side controls on the same physical edge in RTL. Profiles: `iPhone Duo (outer)` / `iPhone Duo (inner)` in `test/mobile/devices.ts`; unit coverage in `test/viewport-shape-change.test.ts`. + **WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](docs/architecture-invariants.md#webgl-renderer-toggle) **Shell keyboard accessory bar + one-shot Ctrl** (issue #262, `keyboard-accessory.js`): a **shell**-mode session automatically swaps the mobile accessory bar for terminal controls (Ctrl, Esc, Tab, four arrows, paste, dismiss); every other mode keeps the agent bar. `setMode()` now records the user's `extendedKeyboardBar` preference as the **base** layout and `refreshForActiveSession()` (called from `selectSession`) resolves base-vs-shell, so a settings save during a shell session cannot yank the bar away and switching back restores the user's choice. ⚠️ **Ctrl is a ONE-SHOT modifier applied in `terminal.onData`, not in a keydown handler**: a virtual keyboard emits no usable key events, so the character only exists as onData text. The hook sits AFTER `shouldSuppressTerminalQueryResponse` (xterm answers DA/CPR through onData too, and one of those would silently spend the modifier) and BEFORE every send path, so the control byte follows the normal control-char route. ⚠️ **Not every onData chunk is a keystroke**, and the query filter is not enough on its own: xterm ALSO emits mouse and focus reports on its own initiative, so the hook skips them via `isTerminalFocusOrMouseReport()` (they still reach the PTY, they just don't count as the next key). The mouse half is live — a shell session keeps the NARROW strip, so mouse DECSETs reach the browser and one tap while vim/htop runs spent the armed modifier silently (measured). The focus half is defense in depth: `FOCUS_ESCAPE_FILTER` in `session.ts` strips `\x1b[?1004h` from every PTY read, so `sendFocusMode` never turns on today; if it ever did, the bar's own post-key refocus would emit `\x1b[I` and eat the modifier before the user typed. ⚠️ It must disarm on ALL of: use, second tap, any other accessory key, session switch, keyboard dismissal, and a layout swap; a modifier left armed turns the next innocent keystroke into a control byte. ⚠️ **onData is not the only input path** — with `cjkInputEnabled` on, the CJK textarea owns the keyboard (onData returns early for everything it swallows, and the focus router sends `terminal.focus()` there, which is where the bar refocuses after every key), so `_handleCjkInput()` applies the modifier too. It is that module's single choke point to the PTY, so one call covers typed characters, IME flushes, Enter, backspace and arrows. Without it an armed modifier could neither fire NOR be spent, and survived to a later keystroke. Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Space=NUL / Ctrl+?=DEL); characters with no control equivalent pass through unchanged, like a hardware keyboard. ⚠️ The armed style is `.accessory-btn.accessory-btn-ctrl.armed` (0,3,0) in BOTH stylesheets, and it cannot outrank mobile.css's light-skin repaint at **(0,3,1)** (`:is()` inherits its most specific argument, and that list holds `.btn-toolbar.btn-shell`) — so that rule excludes the state by hand as `.accessory-btn:not(.armed)`. Without the exclusion the armed button renders identically to a resting one on all four light skins, which is worse than no armed style at all. diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index 832b0f805..d94154f80 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -223,6 +223,10 @@ const MobileDetection = { const KeyboardHandler = { VIEWPORT_SETTLE_MS: 80, lastViewportHeight: 0, + // Width of the visual viewport at the previous resize event. A virtual + // keyboard never changes it, so a change here means the device itself + // changed shape. See handleViewportResize(). + lastViewportWidth: 0, keyboardVisible: false, initialViewportHeight: 0, _viewportSettleTimer: null, @@ -241,6 +245,9 @@ const KeyboardHandler = { this.initialViewportHeight = window.visualViewport?.height || window.innerHeight; this.lastViewportHeight = this.initialViewportHeight; + // Seed the width too, or the first resize event reads as a shape change and + // swallows a real keyboard. + this.lastViewportWidth = window.visualViewport?.width || window.innerWidth; // Simple focus handler - scroll input into view after keyboard appears this._focusinHandler = (e) => { @@ -307,13 +314,35 @@ const KeyboardHandler = { this._settleAnchorY = null; }, - /** Handle viewport resize (keyboard show/hide) */ + /** + * Handle viewport resize (keyboard show/hide). + * + * ⚠️ A resize that changes the viewport WIDTH is the device changing shape + * (a rotation, or a foldable opening or closing), and is never a virtual + * keyboard, which only ever takes height. Without that distinction, closing + * an iPhone Duo (626→466pt wide, 890→678pt tall) drops the height by more + * than the 150px threshold, so the app latched `keyboardVisible` with no + * keyboard on screen: the accessory bar appeared, `main` grew 84px of dead + * padding, and `updateAppHeight()` (which bails while the keyboard is up) + * stopped refreshing --app-height. The latch is sticky, because clearing it + * needs the height back within 100px of a baseline that is now a display the + * user is no longer looking at, so it survived until the device was opened + * again. Rotating any phone hit the same latch; the fold just makes it a + * routine gesture rather than a rare one. + * + * The shape-change branch re-baselines instead, which is also what lets a + * keyboard opened AFTER the fold be detected against the new display. + */ handleViewportResize() { const currentHeight = window.visualViewport?.height || window.innerHeight; + const currentWidth = window.visualViewport?.width || window.innerWidth; + const shapeChanged = currentWidth !== this.lastViewportWidth; + this.lastViewportWidth = currentWidth; const heightDiff = this.initialViewportHeight - currentHeight; - // Keyboard appeared (viewport shrunk by more than 150px) - if (heightDiff > 150 && !this.keyboardVisible) { + // Keyboard appeared (viewport shrunk by more than 150px). Both detection + // branches are skipped on a shape change, whichever way the height moved. + if (!shapeChanged && heightDiff > 150 && !this.keyboardVisible) { this.keyboardVisible = true; document.body.classList.add('keyboard-visible'); // While the keyboard is open, size the app to the visual viewport so @@ -324,7 +353,7 @@ const KeyboardHandler = { // Keyboard hidden (viewport grew back close to initial) // Use 100px threshold (not 50) to handle iOS address bar drift, // iOS 26's persistent 24px discrepancy, and Safari bottom bar changes - else if (heightDiff < 100 && this.keyboardVisible) { + else if (!shapeChanged && heightDiff < 100 && this.keyboardVisible) { this.keyboardVisible = false; document.body.classList.remove('keyboard-visible'); this.onKeyboardHide(); @@ -334,10 +363,15 @@ const KeyboardHandler = { } // Update baseline when keyboard is not visible — adapts to address bar - // state changes, orientation changes, and other viewport shifts - if (!this.keyboardVisible) { + // state changes, orientation changes, and other viewport shifts. A shape + // change re-baselines even with the keyboard up (it may genuinely still be + // open, but its old baseline belongs to a display that is gone), and still + // writes --app-height below so the keyboard-open sizing follows the new + // display. + if (shapeChanged || !this.keyboardVisible) { this.initialViewportHeight = currentHeight; - } else { + } + if (this.keyboardVisible) { document.documentElement.style.setProperty('--app-height', `${currentHeight}px`); } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 997e71824..f6ae627fc 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -18066,3 +18066,106 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle transition: none; } } + +/* ============================================================ + === Folding devices: keep dialogs off the hinge === + Apple's "Designing for iPhone Duo" calls the band a partly-open + display folds through a RESERVED REGION: content avoids covering + it, and system components (alerts, sheets, context menus) move + aside for it. On the web that region is described by the CSS + Viewport Segments media features and env() variables, which report + two segments only while a foldable is actually bent. Flat, open + or closed, it is one segment and everything below is inert. + + Codeman's centred overlays are all `position: fixed; inset: 0` + flex-centring boxes, so their dialog lands dead on the hinge in + book pose (a vertical fold) or tabletop pose (a horizontal one). + The fix shrinks the CONTENT box with padding rather than the box + itself, so each overlay's backdrop still covers the whole viewport + and still swallows taps on the far side of the fold. Shrinking + the box would leave the trailing segment unshaded and live. + + ⚠️ Each rule re-states the overlay's OWN gutter, because a + later `padding-right` longhand beats the earlier `padding` + shorthand it is composing with and would otherwise erase it. + test/iphone-duo-fold.test.ts reads both numbers out of this file + and fails if they drift apart. + + ⚠️ Physical sides, not logical ones: dialogs go in the LEFT + segment (and the TOP one in tabletop pose) in every language. The + HIG keeps Duo's side controls on the same physical edge in RTL + because they are aligned with the hardware, and a dialog that + changed sides with the text direction would fight that. + ============================================================ */ + +:root { + /* Width of the trailing strip to leave clear so a centred dialog cannot sit + under a vertical hinge, and the matching bottom strip for a horizontal one. + 0px on every non-folding device, and on a foldable held flat. */ + --fold-inline-end: 0px; + --fold-block-end: 0px; +} + +@media (horizontal-viewport-segments: 2) { + :root { + --fold-inline-end: calc(100vw - env(viewport-segment-right 0 0, 100vw)); + } +} + +@media (vertical-viewport-segments: 2) { + :root { + --fold-block-end: calc(100vh - env(viewport-segment-bottom 0 0, 100vh)); + } +} + +/* No gutter of their own. */ +.modal, +.file-preview-overlay { + padding-right: var(--fold-inline-end); + padding-bottom: var(--fold-block-end); +} + +/* Specificity 0,2,0 on purpose: mobile.css loads after this file and gives the + palette a `padding` SHORTHAND under 768px, exactly the width where a folding + phone lives, so a bare .command-palette-modal rule here would lose to it. The + 0.75rem side gutter is that mobile rule's; the desktop rule sets no side + padding, so composing with it is a no-op above 768px. */ +.modal.command-palette-modal { + padding-right: calc(0.75rem + var(--fold-inline-end)); +} + +.path-picker-overlay { + padding-right: calc(16px + var(--fold-inline-end)); + padding-bottom: calc(16px + var(--fold-block-end)); +} + +.path-preview-overlay { + padding-right: calc(18px + var(--fold-inline-end)); + padding-bottom: calc(18px + var(--fold-block-end)); +} + +.offline-overlay { + padding-right: calc(20px + var(--fold-inline-end)); + padding-bottom: calc(20px + var(--safe-area-bottom) + var(--fold-block-end)); +} + +.solo-gone-overlay { + padding-right: calc(24px + var(--fold-inline-end)); + padding-bottom: calc(24px + var(--fold-block-end)); +} + +/* Top-anchored, so only the trailing side and the bottom stop matter. */ +.paste-overlay { + padding-right: var(--fold-inline-end); + padding-bottom: var(--fold-block-end); +} + +/* The response viewer is a bottom sheet, so a vertical hinge running through it + is fine, since it is a wide surface like the terminal and inset dialogs are what + the fold guidance is about. A horizontal hinge is not: in tabletop pose the + sheet would climb out of the bottom segment and fold away mid-transcript. */ +@media (vertical-viewport-segments: 2) { + .response-viewer { + max-height: min(88vh, env(viewport-segment-height 0 1, 88vh)); + } +} diff --git a/test/foldable-layout.test.ts b/test/foldable-layout.test.ts new file mode 100644 index 000000000..46e90de33 --- /dev/null +++ b/test/foldable-layout.test.ts @@ -0,0 +1,234 @@ +/** + * @fileoverview Folding devices: dialogs stay off the hinge, and a fold never + * changes which settings the device is using. + * + * Apple's "Designing for iPhone Duo" calls the band a partly-open display folds + * through a RESERVED REGION: content avoids covering it and system components + * move aside for it. On the web that region is described by the CSS Viewport + * Segments media features and env() variables, so the styles.css section this + * file guards is the whole mechanism. + * + * Two things about it fail silently and neither is observable without the + * hardware, which is why they are pinned here rather than left to a device lab: + * + * 1. Each fold rule RE-STATES the overlay's own gutter, because a later + * `padding-right` longhand beats the earlier `padding` shorthand it composes + * with and would otherwise erase it. The two numbers are read out of the + * stylesheet below and compared, so changing one alone fails here. + * 2. The overlay list is DERIVED, not typed out: every `position: fixed; + * inset: 0` flex-centring box in styles.css must have a fold rule. A new + * overlay added without one would centre its dialog on the hinge, and + * nothing else in the suite would notice. + * + * Parsed with postcss rather than regexes because the values are calc() + * expressions and some of the rules live in @media blocks. Rendered behaviour + * needs a real foldable; this is the cheap regression fence. Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import postcss, { type Rule } from 'postcss'; +import { describe, expect, it } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const STYLES = postcss.parse(readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8')); +const MOBILE = postcss.parse(readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8')); + +type Decls = Record; + +function declsOf(rule: Rule): Decls { + const out: Decls = {}; + rule.walkDecls((d) => { + out[d.prop] = d.value; + }); + return out; +} + +/** Every rule in a stylesheet whose selector list contains `selector`. */ +function rulesFor(root: postcss.Root, selector: string): Rule[] { + const found: Rule[] = []; + root.walkRules((rule) => { + if (rule.selectors.includes(selector)) found.push(rule); + }); + return found; +} + +/** + * The centred overlays, derived from the stylesheet. `.modal` is `display:none` + * until `.modal.active`, so display is deliberately not part of the shape. + */ +const CENTRED_OVERLAYS: { selector: string; decls: Decls }[] = []; +STYLES.walkRules((rule) => { + const d = declsOf(rule); + if (d.position === 'fixed' && d.inset === '0' && d['justify-content'] === 'center') { + CENTRED_OVERLAYS.push({ selector: rule.selector, decls: d }); + } +}); + +/** + * The side of a `padding` shorthand that applies to `side`. Every centred + * overlay uses a one-value shorthand today; anything else throws rather than + * being guessed at, since a wrong guess would silently weaken the comparison. + */ +function shorthandSide(value: string): string { + const parts = value.trim().split(/\s+/); + if (parts.length !== 1) throw new Error(`multi-value padding shorthand not handled: ${value}`); + return parts[0]; +} + +/** What an overlay's padding on `side` resolves to before the fold rule. */ +function effectivePadding(decls: Decls, side: 'right' | 'bottom'): string | null { + const longhand = decls[`padding-${side}`]; + if (longhand) return longhand; + if (decls.padding) return shorthandSide(decls.padding); + return null; +} + +/** The value a fold rule must carry to add `foldVar` without dropping `base`. */ +function composed(base: string | null, foldVar: string): string { + if (base === null || base === '0' || base === '0px') return `var(${foldVar})`; + const inner = base.startsWith('calc(') ? base.slice('calc('.length, -1) : base; + return `calc(${inner} + var(${foldVar}))`; +} + +/** The rule that adds the fold inset to `selector`, wherever it lives. */ +function foldRuleFor(selector: string): Rule | undefined { + return STYLES.nodes + .filter((n): n is Rule => n.type === 'rule') + .find((rule) => { + if (!rule.selectors.some((s) => s === selector || s.endsWith(selector))) return false; + const d = declsOf(rule); + return Object.values(d).some((v) => v.includes('--fold-inline-end') || v.includes('--fold-block-end')); + }); +} + +describe('fold reserved region: custom properties', () => { + it('defaults to zero, so nothing moves on a device that does not fold', () => { + const roots = rulesFor(STYLES, ':root').map(declsOf); + const defaults = roots.filter((d) => d['--fold-inline-end'] || d['--fold-block-end']); + + // The overriding definitions live inside @media blocks, which walkRules + // reaches too, so the unconditional one is the last top-level :root. + expect(defaults.length).toBeGreaterThanOrEqual(3); + expect(defaults[0]['--fold-inline-end']).toBe('0px'); + expect(defaults[0]['--fold-block-end']).toBe('0px'); + }); + + it('measures the strip from the LEADING segment in each axis', () => { + // env() indices are [column, row] with (0,0) the top-left segment, so the + // left segment's right edge is `0 0` and the top segment's bottom edge is + // `0 0` as well. Swapping an index silently measures the wrong strip. + const byQuery = new Map(); + STYLES.walkAtRules('media', (at) => { + at.walkRules(':root', (rule) => byQuery.set(at.params, declsOf(rule))); + }); + + expect(byQuery.get('(horizontal-viewport-segments: 2)')?.['--fold-inline-end']).toBe( + 'calc(100vw - env(viewport-segment-right 0 0, 100vw))' + ); + expect(byQuery.get('(vertical-viewport-segments: 2)')?.['--fold-block-end']).toBe( + 'calc(100vh - env(viewport-segment-bottom 0 0, 100vh))' + ); + }); + + it('caps the response viewer to the bottom segment in tabletop pose', () => { + // A vertical hinge through a full-width bottom sheet is fine; a horizontal + // one folds the transcript away mid-read. + const rule = rulesFor(STYLES, '.response-viewer').find((r) => + declsOf(r)['max-height']?.includes('viewport-segment') + ); + + expect(rule?.parent).toMatchObject({ params: '(vertical-viewport-segments: 2)' }); + expect(declsOf(rule!)['max-height']).toBe('min(88vh, env(viewport-segment-height 0 1, 88vh))'); + }); +}); + +describe('fold reserved region: every centred overlay is covered', () => { + it('finds the overlays it is meant to guard', () => { + // A rename that empties this list would turn every assertion below into a + // no-op, so the count is pinned. + expect(CENTRED_OVERLAYS.length).toBe(7); + }); + + it.each(CENTRED_OVERLAYS.map((o) => [o.selector, o] as const))('%s keeps its dialog out of the hinge', (_, o) => { + const fold = foldRuleFor(o.selector); + expect(fold, `${o.selector} has no fold rule`).toBeDefined(); + + const d = declsOf(fold!); + expect(d['padding-right']).toBe(composed(effectivePadding(o.decls, 'right'), '--fold-inline-end')); + expect(d['padding-bottom']).toBe(composed(effectivePadding(o.decls, 'bottom'), '--fold-block-end')); + }); + + it('outranks the padding shorthand mobile.css gives the command palette', () => { + // mobile.css loads after styles.css and sets a `padding` SHORTHAND on + // .command-palette-modal under 768px, exactly the width a folding phone + // lives at, so a bare .command-palette-modal rule here would lose to it. + const mobileRule = rulesFor(MOBILE, '.command-palette-modal').find((r) => declsOf(r).padding); + expect(mobileRule, 'mobile.css no longer pads the palette; this rule can be simplified').toBeDefined(); + + const sideGutter = declsOf(mobileRule!).padding.trim().split(/\s+/)[1]; + const fold = foldRuleFor('.command-palette-modal'); + + expect(fold?.selector).toBe('.modal.command-palette-modal'); + expect(declsOf(fold!)['padding-right']).toBe(`calc(${sideGutter} + var(--fold-inline-end))`); + }); +}); + +/** + * Load the real MobileDetection against a given UA and viewport width. + * `const MobileDetection = {...}` is lexical, so the export rides the same + * script, the recipe used by the other mobile-handlers tests. + */ +function detectionFor(userAgent: string, width: number) { + const context = vm.createContext({ + console, + navigator: { userAgent, maxTouchPoints: 5 }, + window: { + innerWidth: width, + innerHeight: 800, + addEventListener: () => {}, + matchMedia: () => ({ matches: true }), + }, + document: { body: { classList: { add: () => {}, remove: () => {} } }, addEventListener: () => {} }, + setTimeout: () => 1, + clearTimeout: () => {}, + }); + vm.runInContext( + `${readFileSync(resolve(PUBLIC, 'mobile-handlers.js'), 'utf8')}\nglobalThis.__MD = MobileDetection;`, + context, + { filename: 'mobile-handlers.js' } + ); + return (context as unknown as { __MD: { isHandheldDevice(): boolean; getDeviceType(): string } }).__MD; +} + +describe('a fold never changes which settings the device is using', () => { + // Per-device settings are namespaced on isHandheldDevice(), which is + // form-factor based precisely so it holds still while getDeviceType() (a + // layout decision) follows the width. A posture change that flipped the + // namespace would drop every opt-in setting the user saved while folded, and + // an Android foldable really does reload the page when it opens. + const postures = [ + { name: 'iPhone Duo (outer)', ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) Mobile/15E148', w: 466 }, + { name: 'iPhone Duo (inner)', ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) Mobile/15E148', w: 626 }, + { name: 'Find N5 (folded)', ua: 'Mozilla/5.0 (Linux; Android 15; CPH2671) Mobile Safari/537.36', w: 404 }, + { name: 'Find N5 (unfolded)', ua: 'Mozilla/5.0 (Linux; Android 15; CPH2671) Mobile Safari/537.36', w: 1124 }, + ]; + + it.each(postures)('$name stays handheld', ({ ua, w }) => { + expect(detectionFor(ua, w).isHandheldDevice()).toBe(true); + }); + + it('lets the layout follow the width even when it crosses a breakpoint', () => { + const n5 = postures[3]; + expect(detectionFor(n5.ua, n5.w).getDeviceType()).toBe('desktop'); + expect(detectionFor(postures[2].ua, postures[2].w).getDeviceType()).toBe('mobile'); + }); + + it('gives both iPhone Duo displays the tablet layout', () => { + // 466 and 626 both sit above the 430px phone cut and below 768. Deliberate + // (see shouldUseMobileOverview), and pinned because a 5.4" phone landing in + // the tablet band is the kind of thing that looks like a bug later. + expect(detectionFor(postures[0].ua, postures[0].w).getDeviceType()).toBe('tablet'); + expect(detectionFor(postures[1].ua, postures[1].w).getDeviceType()).toBe('tablet'); + }); +}); diff --git a/test/mobile/devices.ts b/test/mobile/devices.ts index 193ac6ed9..259760e2d 100644 --- a/test/mobile/devices.ts +++ b/test/mobile/devices.ts @@ -279,6 +279,28 @@ const customEntries: DeviceEntry[] = [ // resolution CSS viewport crosses Codeman's desktop breakpoint while the // browser remains a mobile/touch device. custom('OPPO Find N5 (unfolded)', 1124, 1240, 2, ANDROID_MOBILE_UA('15', 'CPH2671'), false), + + // iPhone Duo, both postures. Apple publishes pixels, not points: the outer + // display is 1398x2034 and the inner one 1878x2670, both @3x (460 and 430 + // ppi over 5.36" and 7.58" diagonals), so the CSS viewports below are those + // divided by 3. + // + // No browser-chrome allowance is subtracted, unlike the other iOS entries: + // per Apple's "Designing for iPhone Duo", the system moves toolbars and tab + // bars to the SIDE on the outer display and on the inner one in landscape, + // so the ~193pt vertical allowance copied from other iPhones would be wrong + // in both axes. The registry's other foldable (Find N5) uses the full + // viewport for the same reason. + // + // The PAIR is what earns its place here. Both postures land in the tablet + // band (466 and 626 are each above the 430px phone cut and below 768), so a + // 5.4" phone in someone's hand gets the roomier layout. Deliberate, per the + // note on shouldUseMobileOverview(), and worth a profile precisely because it + // is easy to regress into a phone-width assumption. What must NOT move with + // the fold is the per-device settings identity, which is UA-based and + // therefore identical across the two; test/mobile/settings.test.ts pins it. + custom('iPhone Duo (outer)', 466, 678, 3, IOS_MOBILE_UA('26_0'), true), + custom('iPhone Duo (inner)', 626, 890, 3, IOS_MOBILE_UA('26_0'), true), ]; // --------------------------------------------------------------------------- diff --git a/test/mobile/settings.test.ts b/test/mobile/settings.test.ts index 5d6282b20..8965f0c68 100644 --- a/test/mobile/settings.test.ts +++ b/test/mobile/settings.test.ts @@ -402,6 +402,46 @@ describe('Settings Modal', () => { } }); + it('keeps handheld settings and finds no keyboard when an iPhone Duo closes', async () => { + // The Duo pair does not cross the desktop breakpoint the way Find N5 does + // (466 and 626 are both in the tablet band), so what this covers is the + // other half of "a continuous experience as the device opens and closes": + // the fold takes 212px of height, which handleViewportResize() used to + // read as the virtual keyboard appearing. Unit-covered in + // test/viewport-shape-change.test.ts; this drives the real resize. + const inner = DEVICE_REGISTRY.find((entry) => entry.name === 'iPhone Duo (inner)')!; + const outer = DEVICE_REGISTRY.find((entry) => entry.name === 'iPhone Duo (outer)')!; + const { page, context } = await createDevicePage(inner, BASE_URL, 'chromium'); + + try { + await page.evaluate((key) => { + localStorage.setItem(key, JSON.stringify({ showResponseViewer: true })); + }, STORAGE_KEYS.SETTINGS_MOBILE); + await page.reload({ waitUntil: WAIT.DOM_CONTENT_LOADED }); + await page.waitForTimeout(WAIT.SSE_CONNECT); + + await page.setViewportSize(outer.viewport); + await page.waitForTimeout(WAIT.SSE_CONNECT); + + const state = await page.evaluate(() => ({ + handheld: (window as any).MobileDetection.isHandheldDevice(), + storageKey: (window as any).app.getSettingsStorageKey(), + // The two user-visible symptoms of the latch. KeyboardHandler itself + // is a script-scope const with no window export, and the flag is + // asserted directly in the unit test. + keyboardClass: document.body.classList.contains('keyboard-visible'), + mainPadding: (document.querySelector('.main') as HTMLElement | null)?.style.paddingBottom ?? '', + })); + + expect(state.handheld).toBe(true); + expect(state.storageKey).toBe(STORAGE_KEYS.SETTINGS_MOBILE); + expect(state.keyboardClass).toBe(false); + expect(state.mainPadding).toBe(''); + } finally { + await context.close(); + } + }); + it('keeps handheld settings when a foldable unfolds past the desktop breakpoint', async () => { const device = DEVICE_REGISTRY.find((entry) => entry.name === 'OPPO Find N5 (unfolded)')!; const { page, context } = await createDevicePage(device, BASE_URL, 'chromium'); diff --git a/test/viewport-shape-change.test.ts b/test/viewport-shape-change.test.ts new file mode 100644 index 000000000..34b6c5507 --- /dev/null +++ b/test/viewport-shape-change.test.ts @@ -0,0 +1,216 @@ +// Port: none (pure logic in a vm context: no browser, no server). +// +// A virtual keyboard only ever takes HEIGHT off the visual viewport. Anything +// that changes its WIDTH is the device changing shape: a rotation, or a +// foldable opening or closing. +// +// KeyboardHandler.handleViewportResize() used to read any height drop over +// 150px as the keyboard appearing, so closing a foldable latched +// `keyboardVisible` with no keyboard on screen: the accessory bar appeared, +// `main` grew 84px of dead padding, and updateAppHeight() (which bails while +// the keyboard is up) stopped refreshing --app-height. The latch is sticky: +// clearing it needs the height back within 100px of a baseline belonging to a +// display the user is no longer looking at, so it survived until the device was +// opened again. Rotating any phone hit the same latch. +// +// Lives outside test/mobile/ deliberately, because that suite is +// Playwright-driven and excluded from `npm run test:ci`, so a regression +// guarded only there is invisible to CI (same reasoning as the note in +// mobile-keyboard-bottom-padding.test.ts). +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile-handlers.js'), 'utf8'); + +interface Handler { + init(): void; + handleViewportResize(): void; + keyboardVisible: boolean; + initialViewportHeight: number; + lastViewportWidth: number; +} + +/** iPhone Duo, both postures, in CSS px (see test/mobile/devices.ts). */ +const DUO_INNER = { width: 626, height: 890 }; +const DUO_OUTER = { width: 466, height: 678 }; +/** iOS keyboard over the inner display: height only. */ +const KEYBOARD_HEIGHT = 300; + +/** + * Load mobile-handlers.js against a fake DOM and return its KeyboardHandler + * plus the mutable viewport it reads. + * + * `const KeyboardHandler = {...}` is a lexical binding that does not survive to + * a second `vm.runInContext`, so the export is appended to the SAME script. + */ +function loadHandler(start: { width: number; height: number }) { + const viewport = { ...start, offsetTop: 0, addEventListener: () => {}, removeEventListener: () => {} }; + const bodyClasses = new Set(); + const appHeight: string[] = []; + + const context = vm.createContext({ + console, + app: { relayoutMobileSubagentWindows: () => {} }, + navigator: { userAgent: 'iPhone', maxTouchPoints: 5 }, + window: { + get innerWidth() { + return viewport.width; + }, + get innerHeight() { + return viewport.height; + }, + visualViewport: viewport, + addEventListener: () => {}, + removeEventListener: () => {}, + matchMedia: () => ({ matches: true }), + scrollTo: () => {}, + }, + document: { + body: { + classList: { + add: (c: string) => bodyClasses.add(c), + remove: (c: string) => bodyClasses.delete(c), + }, + }, + documentElement: { + style: { + setProperty: (name: string, value: string) => { + if (name === '--app-height') appHeight.push(value); + }, + }, + }, + addEventListener: () => {}, + removeEventListener: () => {}, + getElementById: () => null, + querySelector: () => null, + }, + setTimeout: () => 1, + clearTimeout: () => {}, + }); + + vm.runInContext(`${SOURCE}\nglobalThis.__KH = KeyboardHandler;`, context, { filename: 'mobile-handlers.js' }); + const handler = (context as unknown as { __KH: Handler }).__KH; + handler.init(); + + /** Move the viewport and fire the resize the browser would fire. */ + const resizeTo = (width: number, height: number) => { + viewport.width = width; + viewport.height = height; + handler.handleViewportResize(); + }; + + return { handler, resizeTo, bodyClasses, appHeight }; +} + +describe('handleViewportResize: height-only changes are the keyboard', () => { + it('detects the keyboard opening', () => { + const { handler, resizeTo, bodyClasses } = loadHandler(DUO_INNER); + + resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT); + + expect(handler.keyboardVisible).toBe(true); + expect(bodyClasses.has('keyboard-visible')).toBe(true); + // The baseline must survive the keyboard, or closing it is undetectable. + expect(handler.initialViewportHeight).toBe(DUO_INNER.height); + }); + + it('detects the keyboard closing', () => { + const { handler, resizeTo, bodyClasses } = loadHandler(DUO_INNER); + + resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT); + resizeTo(DUO_INNER.width, DUO_INNER.height); + + expect(handler.keyboardVisible).toBe(false); + expect(bodyClasses.has('keyboard-visible')).toBe(false); + }); + + it('reads a drop on the very first resize as the keyboard', () => { + // init() has to seed lastViewportWidth, or this first event looks like a + // width change (0 → 626) and swallows a real keyboard. + const { handler, resizeTo } = loadHandler(DUO_INNER); + + expect(handler.lastViewportWidth).toBe(DUO_INNER.width); + resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT); + + expect(handler.keyboardVisible).toBe(true); + }); + + it('ignores address-bar drift, which is under the threshold', () => { + const { handler, resizeTo } = loadHandler(DUO_INNER); + + resizeTo(DUO_INNER.width, DUO_INNER.height - 90); + + expect(handler.keyboardVisible).toBe(false); + }); +}); + +describe('handleViewportResize: width changes are the device changing shape', () => { + it('does not read closing a foldable as the keyboard', () => { + const { handler, resizeTo, bodyClasses } = loadHandler(DUO_INNER); + + // 890 → 678 is a 212px drop, well past the 150px keyboard threshold. + resizeTo(DUO_OUTER.width, DUO_OUTER.height); + + expect(handler.keyboardVisible).toBe(false); + expect(bodyClasses.has('keyboard-visible')).toBe(false); + }); + + it('re-baselines to the display it moved to', () => { + const { handler, resizeTo } = loadHandler(DUO_INNER); + + resizeTo(DUO_OUTER.width, DUO_OUTER.height); + + expect(handler.initialViewportHeight).toBe(DUO_OUTER.height); + }); + + it('detects a keyboard opened after the fold', () => { + // The re-baseline is what makes this work: measured against the old inner + // baseline the outer display's keyboard is a 512px drop that was already + // "open", and against no baseline at all it would never be seen. + const { handler, resizeTo } = loadHandler(DUO_INNER); + + resizeTo(DUO_OUTER.width, DUO_OUTER.height); + resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT); + + expect(handler.keyboardVisible).toBe(true); + }); + + it('does not read opening a foldable as the keyboard closing', () => { + const { handler, resizeTo } = loadHandler(DUO_OUTER); + + resizeTo(DUO_INNER.width, DUO_INNER.height); + + expect(handler.keyboardVisible).toBe(false); + expect(handler.initialViewportHeight).toBe(DUO_INNER.height); + }); + + it('does not read a rotation as the keyboard', () => { + // The same latch, on hardware that has shipped for years: 659 → 330 is a + // 329px drop with no keyboard anywhere. + const { handler, resizeTo, bodyClasses } = loadHandler({ width: 393, height: 659 }); + + resizeTo(852, 330); + + expect(handler.keyboardVisible).toBe(false); + expect(bodyClasses.has('keyboard-visible')).toBe(false); + expect(handler.initialViewportHeight).toBe(330); + }); + + it('keeps --app-height following the new display when the keyboard was up', () => { + // Rotating with the keyboard open cannot be told from folding with it open, + // so keyboardVisible is left alone, but the baseline moves and the + // keyboard-open sizing has to follow the display rather than freeze on the + // one that is gone (updateAppHeight() bails while the keyboard is up). + const { handler, resizeTo, appHeight } = loadHandler(DUO_INNER); + + resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT); + expect(handler.keyboardVisible).toBe(true); + + resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT); + + expect(appHeight.at(-1)).toBe(`${DUO_OUTER.height - KEYBOARD_HEIGHT}px`); + expect(handler.initialViewportHeight).toBe(DUO_OUTER.height - KEYBOARD_HEIGHT); + }); +}); From ef15768e5f10bf7f861c65a85d9d90f20eb7c203 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 14 Sep 2026 15:56:35 +0200 Subject: [PATCH 2/5] fix(mobile): keep the keyboard layout through a fold or rotation with the keyboard up A shape change with the keyboard up re-baselined initialViewportHeight to the SHRUNK visual height, so heightDiff was 0 and the settle event the OS fires at the new width (or any later address-bar drift) satisfied the hide branch and ran onKeyboardHide() with the keyboard still on screen: accessory bar hidden, toolbar lift dropped, main's padding cleared. It could not recover, since no further 150px drop re-arms the show branch against a baseline already sitting at the shrunk height. Baseline to window.innerHeight instead when the keyboard is up: the page sets no interactive-widget, so the keyboard shrinks only the visual viewport and the layout viewport stays the display's full height on both engines, the same fact updateLayoutForKeyboard() relies on. The vm harness now models the two heights separately (resizeTo takes an optional layout height) and pins the fold flavour (626x590, 466x378, 466x378), the rotation flavour (393x359, 852x150, 852x160) and the eventual close. All three fail against the old line. Co-Authored-By: Claude Fable 5.1 --- src/web/public/mobile-handlers.js | 18 ++++++- test/viewport-shape-change.test.ts | 76 +++++++++++++++++++++++++++--- 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index d94154f80..7d815f70a 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -362,13 +362,27 @@ const KeyboardHandler = { MobileDetection.updateAppHeight(); } - // Update baseline when keyboard is not visible — adapts to address bar + // Update baseline when keyboard is not visible: adapts to address bar // state changes, orientation changes, and other viewport shifts. A shape // change re-baselines even with the keyboard up (it may genuinely still be // open, but its old baseline belongs to a display that is gone), and still // writes --app-height below so the keyboard-open sizing follows the new // display. - if (shapeChanged || !this.keyboardVisible) { + // + // ⚠️ With the keyboard up, the new baseline must be the KEYBOARD-FREE + // height of the display the device moved to, which is window.innerHeight + // (the layout viewport; the page sets no interactive-widget, so the + // keyboard shrinks only the visual viewport on both engines, the same + // fact updateLayoutForKeyboard() relies on). Baselining to the SHRUNK + // visual height made heightDiff 0, so the very next same-width resize + // (the settle event the OS animation produces, or any address-bar drift) + // satisfied the hide branch and tore the keyboard layout down with the + // keyboard still on screen, and it could not recover: no further 150px + // drop can re-arm the show branch against a baseline that already sits + // at the shrunk height. + if (shapeChanged) { + this.initialViewportHeight = this.keyboardVisible ? window.innerHeight : currentHeight; + } else if (!this.keyboardVisible) { this.initialViewportHeight = currentHeight; } if (this.keyboardVisible) { diff --git a/test/viewport-shape-change.test.ts b/test/viewport-shape-change.test.ts index 34b6c5507..d76260202 100644 --- a/test/viewport-shape-change.test.ts +++ b/test/viewport-shape-change.test.ts @@ -42,11 +42,18 @@ const KEYBOARD_HEIGHT = 300; * Load mobile-handlers.js against a fake DOM and return its KeyboardHandler * plus the mutable viewport it reads. * + * Two heights are modelled, because the handler reads both: `visualViewport` + * is what the keyboard shrinks, and `window.innerHeight` is the layout + * viewport, which stays the display's full height while the keyboard is up + * (the page sets no interactive-widget, so the default resizes-visual mode + * holds on both engines). resizeTo() ties them unless a test says otherwise. + * * `const KeyboardHandler = {...}` is a lexical binding that does not survive to * a second `vm.runInContext`, so the export is appended to the SAME script. */ function loadHandler(start: { width: number; height: number }) { const viewport = { ...start, offsetTop: 0, addEventListener: () => {}, removeEventListener: () => {} }; + const layout = { height: start.height }; const bodyClasses = new Set(); const appHeight: string[] = []; @@ -59,7 +66,7 @@ function loadHandler(start: { width: number; height: number }) { return viewport.width; }, get innerHeight() { - return viewport.height; + return layout.height; }, visualViewport: viewport, addEventListener: () => {}, @@ -94,10 +101,16 @@ function loadHandler(start: { width: number; height: number }) { const handler = (context as unknown as { __KH: Handler }).__KH; handler.init(); - /** Move the viewport and fire the resize the browser would fire. */ - const resizeTo = (width: number, height: number) => { + /** + * Move the visual viewport and fire the resize the browser would fire. + * `layoutHeight` is window.innerHeight; pass the display's full height to + * model a keyboard that is up (the visual viewport shrunk, the layout one + * not), and leave it out for a plain resize, where the two agree. + */ + const resizeTo = (width: number, height: number, layoutHeight = height) => { viewport.width = width; viewport.height = height; + layout.height = layoutHeight; handler.handleViewportResize(); }; @@ -205,12 +218,63 @@ describe('handleViewportResize: width changes are the device changing shape', () // one that is gone (updateAppHeight() bails while the keyboard is up). const { handler, resizeTo, appHeight } = loadHandler(DUO_INNER); - resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT); + resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT, DUO_INNER.height); expect(handler.keyboardVisible).toBe(true); - resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT); + resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT, DUO_OUTER.height); expect(appHeight.at(-1)).toBe(`${DUO_OUTER.height - KEYBOARD_HEIGHT}px`); - expect(handler.initialViewportHeight).toBe(DUO_OUTER.height - KEYBOARD_HEIGHT); + // The baseline is the new display's KEYBOARD-FREE height (window.innerHeight), + // never the shrunk visual height, or the next resize reads as the keyboard + // closing (see the two tests below). + expect(handler.initialViewportHeight).toBe(DUO_OUTER.height); + }); + + it('survives the settle event that follows a fold with the keyboard up', () => { + // The OS animates a shape change, so the browser fires one resize that + // changes the width and then at least one more at the settled width (see + // _scheduleViewportSettle). Baselining the first to the SHRUNK visual + // height made heightDiff 0 on the second, which satisfied the hide branch + // and tore the keyboard layout down with the keyboard still on screen, and + // nothing could re-arm the show branch against that baseline. + const { handler, resizeTo, bodyClasses } = loadHandler(DUO_INNER); + + resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT, DUO_INNER.height); + resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT, DUO_OUTER.height); + resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT, DUO_OUTER.height); + + expect(handler.keyboardVisible).toBe(true); + expect(bodyClasses.has('keyboard-visible')).toBe(true); + expect(handler.initialViewportHeight).toBe(DUO_OUTER.height); + }); + + it('survives the settle event that follows a rotation with the keyboard up', () => { + // Same latch on a plain phone: portrait 393x659, keyboard up (visual 359), + // rotate to landscape 852x393 with the keyboard still up (visual 150), + // then the settle event at 160. The old baseline of 150 read the 160 as + // the keyboard closing. + const { handler, resizeTo, bodyClasses } = loadHandler({ width: 393, height: 659 }); + + resizeTo(393, 359, 659); + expect(handler.keyboardVisible).toBe(true); + resizeTo(852, 150, 393); + resizeTo(852, 160, 393); + + expect(handler.keyboardVisible).toBe(true); + expect(bodyClasses.has('keyboard-visible')).toBe(true); + expect(handler.initialViewportHeight).toBe(393); + }); + + it('still detects the keyboard closing after a fold with it up', () => { + // The keyboard-free baseline is what makes the eventual close visible: + // dismissing it returns the visual viewport to the display height. + const { handler, resizeTo, bodyClasses } = loadHandler(DUO_INNER); + + resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT, DUO_INNER.height); + resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT, DUO_OUTER.height); + resizeTo(DUO_OUTER.width, DUO_OUTER.height); + + expect(handler.keyboardVisible).toBe(false); + expect(bodyClasses.has('keyboard-visible')).toBe(false); }); }); From b6dbbbcfe09e54776579e59151646defd09676e9 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 14 Sep 2026 15:56:36 +0200 Subject: [PATCH 3/5] fix(mobile): fold padding keeps phone sheets flush, scopes the palette rule, caps the response viewer under 430px Three cascade problems in the fold reserved-region CSS, each measured by computed style in headless Chromium (styles.css + mobile.css in index.html link order): - The unconditional .path-picker-overlay / .path-preview-overlay fold rules at the end of the file beat the `padding: 0` both overlays set under 600px, so every phone got a 16px and 18px gutter on dialogs built flush (393 and 500px: edges floating off the screen). The fold strip is now restated on a ZERO base inside the same media query: 0/0 without a fold, the strip alone with one, 16/18 plus the strip from 626px up as before. - .modal.command-palette-modal was unscoped, so outside the 430-768px band (where mobile.css pads the palette with a shorthand) it ADDED 0.75rem with no gutter to compose with and pushed the shell 6px off centre at 393, 900 and 1400px, while inside the band the shorthand beat the generic .modal rule on the bottom side and the palette lost its block-end gutter. The compound rule now lives inside that band and restates both sides. - The tabletop cap on .response-viewer lost to mobile.css's `max-height: 92dvh` under 430px (same specificity, later file). mobile.css now carries an identical twin at its end. test/foldable-layout.test.ts simulates the padding cascade across both files at every breakpoint, with and without the fold rules, and requires the two to differ by exactly the fold strip; it also pins the palette rule to the band mobile.css keys on and the response-viewer twin to the styles.css value. Its model reproduces the Chromium numbers, and against the pre-fix stylesheets it fails on all three problems. Co-Authored-By: Claude Fable 5.1 --- src/web/public/mobile.css | 12 ++ src/web/public/styles.css | 50 +++++-- test/foldable-layout.test.ts | 247 ++++++++++++++++++++++++++++++++--- 3 files changed, 284 insertions(+), 25 deletions(-) diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 967452951..fc1bf2197 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -3850,3 +3850,15 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close { transition: none; } } + +/* Folding devices, tabletop pose: cap the response viewer to the bottom + segment. Twin of the rule at the end of styles.css, needed here because the + 430px block above sets `max-height: 92dvh` on the same selector at the same + specificity, and this file loads later, so the styles.css copy loses on a + phone-width foldable. Keep the value identical; test/foldable-layout.test.ts + compares them. */ +@media (vertical-viewport-segments: 2) { + .response-viewer { + max-height: min(88vh, env(viewport-segment-height 0 1, 88vh)); + } +} diff --git a/src/web/public/styles.css b/src/web/public/styles.css index f6ae627fc..ddd9668c7 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -18088,8 +18088,10 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle ⚠️ Each rule re-states the overlay's OWN gutter, because a later `padding-right` longhand beats the earlier `padding` shorthand it is composing with and would otherwise erase it. - test/iphone-duo-fold.test.ts reads both numbers out of this file - and fails if they drift apart. + test/foldable-layout.test.ts reads both numbers out of this file + and fails if they drift apart, and simulates the cascade at every + breakpoint so a base gutter overridden by a later @media block (the + phone path picker below) needs its own zero-base restatement. ⚠️ Physical sides, not logical ones: dialogs go in the LEFT segment (and the TOP one in tabletop pose) in every language. The @@ -18125,13 +18127,19 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle padding-bottom: var(--fold-block-end); } -/* Specificity 0,2,0 on purpose: mobile.css loads after this file and gives the - palette a `padding` SHORTHAND under 768px, exactly the width where a folding - phone lives, so a bare .command-palette-modal rule here would lose to it. The - 0.75rem side gutter is that mobile rule's; the desktop rule sets no side - padding, so composing with it is a no-op above 768px. */ -.modal.command-palette-modal { - padding-right: calc(0.75rem + var(--fold-inline-end)); +/* The palette is a .modal, so the generic rule above covers it everywhere + EXCEPT the 430-768px band, where mobile.css (loaded after this file) pads + it with the SHORTHAND `10vh 0.75rem 0`: that shorthand beats the generic + rule on both sides, so this compound rule (0,2,0) restates that band's own + gutters, 0.75rem at the side and none at the bottom, plus the fold strips. + Scoped to the same band on purpose: unscoped, it ADDED 0.75rem on every + other width, where the palette has no side gutter to compose with, and + pushed the shell 6px off centre (measured at 393, 900 and 1400). */ +@media (max-width: 768px) and (min-width: 430px) { + .modal.command-palette-modal { + padding-right: calc(0.75rem + var(--fold-inline-end)); + padding-bottom: var(--fold-block-end); + } } .path-picker-overlay { @@ -18144,6 +18152,24 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle padding-bottom: calc(18px + var(--fold-block-end)); } +/* Under 600px both dialogs are flush sheets: their `@media (max-width: 600px)` + rules drop the gutter to 0 (borderless right and bottom edges, the picker + docked to the bottom). The two rules above sit later in the file at the same + specificity, so on their own they put a 16px and 18px gutter back on every + phone, fold or no fold (measured at 393 and 500: dialog edges floating 16px + off the screen edge). Restate the fold strip on a ZERO base here. */ +@media (max-width: 600px) { + .path-picker-overlay { + padding-right: var(--fold-inline-end); + padding-bottom: var(--fold-block-end); + } + + .path-preview-overlay { + padding-right: var(--fold-inline-end); + padding-bottom: var(--fold-block-end); + } +} + .offline-overlay { padding-right: calc(20px + var(--fold-inline-end)); padding-bottom: calc(20px + var(--safe-area-bottom) + var(--fold-block-end)); @@ -18163,7 +18189,11 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle /* The response viewer is a bottom sheet, so a vertical hinge running through it is fine, since it is a wide surface like the terminal and inset dialogs are what the fold guidance is about. A horizontal hinge is not: in tabletop pose the - sheet would climb out of the bottom segment and fold away mid-transcript. */ + sheet would climb out of the bottom segment and fold away mid-transcript. + ⚠️ mobile.css carries an identical twin at its end: its 430px block sets + `max-height: 92dvh` at the same specificity and loads later, so this copy + alone loses on a phone-width foldable. test/foldable-layout.test.ts pins + the two values equal. */ @media (vertical-viewport-segments: 2) { .response-viewer { max-height: min(88vh, env(viewport-segment-height 0 1, 88vh)); diff --git a/test/foldable-layout.test.ts b/test/foldable-layout.test.ts index 46e90de33..ba6280810 100644 --- a/test/foldable-layout.test.ts +++ b/test/foldable-layout.test.ts @@ -8,7 +8,7 @@ * Segments media features and env() variables, so the styles.css section this * file guards is the whole mechanism. * - * Two things about it fail silently and neither is observable without the + * Three things about it fail silently and none is observable without the * hardware, which is why they are pinned here rather than left to a device lab: * * 1. Each fold rule RE-STATES the overlay's own gutter, because a later @@ -19,6 +19,14 @@ * inset: 0` flex-centring box in styles.css must have a fold rule. A new * overlay added without one would centre its dialog on the hinge, and * nothing else in the suite would notice. + * 3. The gutter an overlay ends up with is a CASCADE across two files and + * several breakpoints, not one rule: a later @media block can zero it (the + * phone path picker under 600px), mobile.css can replace it with a + * shorthand (the palette between 430 and 768px) and, loading later, can + * outrank a same-specificity rule (the response viewer under 430px). So the + * cascade is simulated at every breakpoint, once with the fold rules and + * once without, and the two results must differ by exactly the fold strip. + * Each of the three shipped once with the top-level-only comparison green. * * Parsed with postcss rather than regexes because the values are calc() * expressions and some of the rules live in @media blocks. Rendered behaviour @@ -91,17 +99,160 @@ function composed(base: string | null, foldVar: string): string { return `calc(${inner} + var(${foldVar}))`; } -/** The rule that adds the fold inset to `selector`, wherever it lives. */ +function isFoldValue(value: string): boolean { + return value.includes('--fold-inline-end') || value.includes('--fold-block-end'); +} + +/** Every rule that adds the fold inset to `selector`, top level or inside @media, in source order. */ +function foldRulesFor(selector: string): Rule[] { + const found: Rule[] = []; + STYLES.walkRules((rule) => { + if (!rule.selectors.some((s) => s === selector || s.endsWith(selector))) return; + if (Object.values(declsOf(rule)).some(isFoldValue)) found.push(rule); + }); + return found; +} + +/** The first (unconditional, for the derived overlays) fold rule for `selector`. */ function foldRuleFor(selector: string): Rule | undefined { - return STYLES.nodes - .filter((n): n is Rule => n.type === 'rule') - .find((rule) => { - if (!rule.selectors.some((s) => s === selector || s.endsWith(selector))) return false; - const d = declsOf(rule); - return Object.values(d).some((v) => v.includes('--fold-inline-end') || v.includes('--fold-block-end')); + return foldRulesFor(selector)[0]; +} + +// ─── Cascade simulation ────────────────────────────────────────────────────── +// +// A small model of what the browser does for one element's padding: every rule +// in styles.css then mobile.css (index.html link order) whose selector is a +// class compound matching the element, whose enclosing @media matches the +// width, ordered by specificity then source order, shorthand expanded to the +// side asked for. Deliberately narrow: rules nested inside another rule (the +// skin block) or under an at-rule other than @media / @supports are ignored, +// and a media query with any feature other than min/max-width is treated as +// not matching, which is right for a FLAT device (viewport-segments queries +// only match while bent). The numbers it produces were checked against +// getComputedStyle in headless Chromium at every width below. + +type Side = 'right' | 'bottom'; + +interface PaddingDecl { + order: number; + file: 'styles.css' | 'mobile.css'; + classes: string[]; + specificity: number; + media: string | null; + prop: 'padding' | `padding-${Side}`; + value: string; + fold: boolean; +} + +/** `.a.b` -> ['a', 'b']; anything that is not a pure class compound -> null. */ +function classCompound(selector: string): string[] | null { + const trimmed = selector.trim(); + if (!/^(\.[A-Za-z0-9_-]+)+$/.test(trimmed)) return null; + return trimmed.slice(1).split('.'); +} + +const PADDING_DECLS: PaddingDecl[] = []; +{ + let order = 0; + for (const [file, root] of [ + ['styles.css', STYLES], + ['mobile.css', MOBILE], + ] as const) { + root.walkRules((rule) => { + const media: string[] = []; + let nested = false; + for (let p = rule.parent; p && p.type !== 'root'; p = p.parent) { + if (p.type === 'rule') nested = true; + else if (p.type === 'atrule' && p.name === 'media') media.push(p.params); + else if (p.type === 'atrule' && p.name !== 'supports') nested = true; + } + if (nested) return; + for (const selector of rule.selectors) { + const classes = classCompound(selector); + if (!classes) continue; + rule.each((node) => { + if (node.type !== 'decl') return; + if (!/^padding(-right|-bottom)?$/.test(node.prop)) return; + PADDING_DECLS.push({ + order: order++, + file, + classes, + specificity: classes.length, + media: media.length ? media.join(' and ') : null, + prop: node.prop as PaddingDecl['prop'], + value: node.value, + fold: isFoldValue(node.value), + }); + }); + } }); + } } +/** Does a width-only media query match `width`? Anything else is "not on a flat device". */ +function mediaMatches(params: string, width: number): boolean { + return params.split(',').some((alt) => + alt.split(/\s+and\s+/).every((term) => { + const t = term.trim(); + if (t === 'screen' || t === 'all') return true; + const m = /^\((max|min)-width:\s*(\d+(?:\.\d+)?)px\)$/.exec(t); + if (!m) return false; + return m[1] === 'max' ? width <= Number(m[2]) : width >= Number(m[2]); + }) + ); +} + +/** Split a shorthand on whitespace outside parentheses. */ +function tokens(value: string): string[] { + const out: string[] = []; + let depth = 0; + let cur = ''; + for (const ch of value.trim()) { + if (ch === '(') depth++; + if (ch === ')') depth--; + if (/\s/.test(ch) && depth === 0) { + if (cur) out.push(cur); + cur = ''; + } else cur += ch; + } + if (cur) out.push(cur); + return out; +} + +/** The side of a 1-4 value `padding` shorthand. */ +function shorthandSideOf(value: string, side: Side): string { + const t = tokens(value); + if (t.length < 1 || t.length > 4) throw new Error(`padding shorthand not handled: ${value}`); + const [top, right = top, bottom = top, left = right] = t; + void left; + return side === 'right' ? right : bottom; +} + +/** + * What `padding-` resolves to for an element carrying `classes` at + * `width`, as the declaration VALUE that wins (null when nothing sets it). + * `withFold: false` drops every declaration that references a fold variable, + * which is the cascade a non-folding build would have. + */ +function cascadedPadding(classes: string[], side: Side, width: number, withFold: boolean): string | null { + const have = new Set(classes); + const winners = PADDING_DECLS.filter( + (d) => + (withFold || !d.fold) && + (d.file === 'styles.css' || width <= 1023) && + (d.media === null || mediaMatches(d.media, width)) && + d.classes.every((c) => have.has(c)) && + (d.prop === 'padding' || d.prop === `padding-${side}`) + ); + winners.sort((a, b) => a.specificity - b.specificity || a.order - b.order); + const last = winners.at(-1); + if (!last) return null; + return last.prop === 'padding' ? shorthandSideOf(last.value, side) : last.value; +} + +/** Every breakpoint either stylesheet keys on, plus a phone, a Duo posture and a desktop. */ +const WIDTHS = [393, 430, 500, 600, 626, 768, 900, 1400]; + describe('fold reserved region: custom properties', () => { it('defaults to zero, so nothing moves on a device that does not fold', () => { const roots = rulesFor(STYLES, ':root').map(declsOf); @@ -159,18 +310,84 @@ describe('fold reserved region: every centred overlay is covered', () => { expect(d['padding-bottom']).toBe(composed(effectivePadding(o.decls, 'bottom'), '--fold-block-end')); }); - it('outranks the padding shorthand mobile.css gives the command palette', () => { + /** + * The elements whose padding cascade is simulated: every derived overlay as + * a bare element, plus the open command palette, which is a `.modal` wearing + * two more classes and the one overlay mobile.css pads with a shorthand. + */ + const ELEMENTS: { name: string; classes: string[] }[] = [ + ...CENTRED_OVERLAYS.map((o) => ({ name: o.selector, classes: classCompound(o.selector)! })), + { name: '.modal.command-palette-modal.active', classes: ['modal', 'command-palette-modal', 'active'] }, + ]; + + it('simulates the cascade the browser measured', () => { + // Anchors for the model, all read off getComputedStyle in headless + // Chromium (styles.css + mobile.css in index.html link order): the phone + // path picker is flush under 600px and keeps its 16px gutter above it; + // the palette carries mobile.css's 0.75rem side gutter only inside the + // 430-768px band. A model that cannot reproduce these numbers proves + // nothing about the fold rules built on top of them. + const picker = ['path-picker-overlay']; + expect(cascadedPadding(picker, 'right', 393, false)).toBe('0'); + expect(cascadedPadding(picker, 'right', 626, false)).toBe('16px'); + const palette = ELEMENTS.at(-1)!.classes; + expect(cascadedPadding(palette, 'right', 393, false)).toBeNull(); + expect(cascadedPadding(palette, 'right', 500, false)).toBe('0.75rem'); + expect(cascadedPadding(palette, 'bottom', 500, false)).toBe('0'); + expect(cascadedPadding(palette, 'right', 900, false)).toBeNull(); + }); + + it.each(ELEMENTS.map((e) => [e.name, e.classes] as const))( + '%s ends up with exactly its own gutter plus the fold strip at every breakpoint', + (_, classes) => { + for (const width of WIDTHS) { + for (const side of ['right', 'bottom'] as const) { + const foldVar = side === 'right' ? '--fold-inline-end' : '--fold-block-end'; + const base = cascadedPadding(classes, side, width, false); + const actual = cascadedPadding(classes, side, width, true); + expect(actual, `padding-${side} at ${width}px (base ${base})`).toBe(composed(base, foldVar)); + } + } + } + ); + + it('composes with the padding shorthand mobile.css gives the command palette, inside that band only', () => { // mobile.css loads after styles.css and sets a `padding` SHORTHAND on - // .command-palette-modal under 768px, exactly the width a folding phone - // lives at, so a bare .command-palette-modal rule here would lose to it. + // .command-palette-modal between 430 and 768px, exactly where a folding + // phone lives, so a bare .command-palette-modal rule would lose to it and + // the compound rule has to restate BOTH of that band's gutters. Scoped to + // the same band: unscoped, it added 0.75rem where the palette has no side + // gutter at all and pushed the shell 6px off centre. const mobileRule = rulesFor(MOBILE, '.command-palette-modal').find((r) => declsOf(r).padding); expect(mobileRule, 'mobile.css no longer pads the palette; this rule can be simplified').toBeDefined(); + const band = mobileRule!.parent; + expect(band).toMatchObject({ type: 'atrule', name: 'media' }); + + const shorthand = declsOf(mobileRule!).padding; + const fold = foldRulesFor('.command-palette-modal'); + expect(fold).toHaveLength(1); + expect(fold[0].selector).toBe('.modal.command-palette-modal'); + expect(fold[0].parent).toMatchObject({ type: 'atrule', name: 'media', params: (band as postcss.AtRule).params }); + expect(declsOf(fold[0])['padding-right']).toBe(composed(shorthandSideOf(shorthand, 'right'), '--fold-inline-end')); + expect(declsOf(fold[0])['padding-bottom']).toBe(composed(shorthandSideOf(shorthand, 'bottom'), '--fold-block-end')); + }); - const sideGutter = declsOf(mobileRule!).padding.trim().split(/\s+/)[1]; - const fold = foldRuleFor('.command-palette-modal'); + it('gives the response viewer cap a later twin in mobile.css', () => { + // mobile.css sets `max-height` on .response-viewer at the same specificity + // under 430px and loads later, so the styles.css cap alone loses on a + // phone-width foldable. The twin must come after that rule and carry the + // identical value. + const capOf = (root: postcss.Root) => + rulesFor(root, '.response-viewer').find((r) => declsOf(r)['max-height']?.includes('viewport-segment')); + const styles = capOf(STYLES); + const mobile = capOf(MOBILE); + expect(mobile, 'mobile.css has no twin of the tabletop cap').toBeDefined(); + expect(mobile!.parent).toMatchObject({ params: '(vertical-viewport-segments: 2)' }); + expect(declsOf(mobile!)['max-height']).toBe(declsOf(styles!)['max-height']); - expect(fold?.selector).toBe('.modal.command-palette-modal'); - expect(declsOf(fold!)['padding-right']).toBe(`calc(${sideGutter} + var(--fold-inline-end))`); + const competing = rulesFor(MOBILE, '.response-viewer').filter((r) => r !== mobile && declsOf(r)['max-height']); + expect(competing.length).toBeGreaterThan(0); + for (const rule of competing) expect(rule.source!.start!.line).toBeLessThan(mobile!.source!.start!.line); }); }); From 2f9fc722526c6b5636b99675cc332babccced454 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 14 Sep 2026 15:56:37 +0200 Subject: [PATCH 4/5] docs(mobile): record the fold cascade traps and the keyboard-free baseline CLAUDE.md's folding-devices rule gains the two new invariants (a shape change with the keyboard up baselines to window.innerHeight; a base gutter overridden by a later @media block needs its own zero-base fold restatement, and a compound rule written against a mobile.css shorthand is scoped to that band) plus the architecture-invariants pointer it lacked; the new Folding devices section there carries the mechanisms and the measurements. The device count is 138 since the two Duo profiles landed (68 Playwright + 70 custom). Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 4 ++-- docs/architecture-invariants.md | 8 ++++++++ test/mobile/README.md | 12 ++++++------ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 345037457..a0d78ada4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -326,7 +326,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Foldable settings identity**: responsive layout is width-driven via `MobileDetection.getDeviceType()`, but the localStorage namespace uses `MobileDetection.isHandheldDevice()` so an unfolded Android foldable keeps `codeman-app-settings-mobile`. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. → [architecture-invariants#foldable-settings-identity](docs/architecture-invariants.md#foldable-settings-identity) -**Folding devices: a fold is not a keyboard, and dialogs avoid the hinge** (Apple's [Designing for iPhone Duo](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo)): ⚠️ **A visual-viewport resize that changes the WIDTH is the device changing shape** (a rotation, or a foldable opening or closing) **and is never the virtual keyboard**, which only ever takes height. `handleViewportResize()` read any height drop over 150px as the keyboard appearing, so closing an iPhone Duo (626→466pt wide, 890→678pt tall) latched `keyboardVisible` with no keyboard on screen: the accessory bar appeared, `main` grew 84px of dead padding, and `updateAppHeight()` (which bails while the keyboard is up) stopped refreshing `--app-height`. The latch is STICKY, since clearing it needs the height back within 100px of a baseline belonging to a display the user is no longer looking at, so it survived until the device was opened again, and rotating any phone hit it too. The shape branch re-baselines instead, which is also what lets a keyboard opened AFTER the fold be detected. ⚠️ `init()` must seed `lastViewportWidth`, or the very first resize reads as a shape change and swallows a real keyboard. ⚠️ **The hinge is a reserved region.** The CSS Viewport Segments media features report two segments only while a foldable is actually bent, and `--fold-inline-end`/`--fold-block-end` (styles.css) measure the strip to keep clear: `0px` on everything else, so the rules are inert by construction rather than by a branch. Codeman's seven centred overlays are all `position: fixed; inset: 0` flex boxes, and each shrinks its CONTENT box with padding rather than the box itself, so the backdrop still covers the far side of the fold and still swallows taps there. ⚠️ Each rule RE-STATES the overlay's own gutter (a later `padding-right` longhand beats the earlier `padding` shorthand it composes with), and the palette needs the compound `.modal.command-palette-modal` because mobile.css loads later and pads it with a shorthand under 768px. `test/foldable-layout.test.ts` DERIVES the overlay list from the stylesheet and compares both numbers, so a new overlay or a moved gutter fails there instead of on hardware nobody has. ⚠️ Physical sides, not logical ones: dialogs sit in the LEFT segment (the TOP one in tabletop pose) in every language, because the HIG keeps Duo's side controls on the same physical edge in RTL. Profiles: `iPhone Duo (outer)` / `iPhone Duo (inner)` in `test/mobile/devices.ts`; unit coverage in `test/viewport-shape-change.test.ts`. +**Folding devices: a fold is not a keyboard, and dialogs avoid the hinge** (Apple's [Designing for iPhone Duo](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo)): ⚠️ **A visual-viewport resize that changes the WIDTH is the device changing shape** (a rotation, or a foldable opening or closing) **and is never the virtual keyboard**, which only ever takes height. `handleViewportResize()` read any height drop over 150px as the keyboard appearing, so closing an iPhone Duo (626→466pt wide, 890→678pt tall) latched `keyboardVisible` with no keyboard on screen: the accessory bar appeared, `main` grew 84px of dead padding, and `updateAppHeight()` (which bails while the keyboard is up) stopped refreshing `--app-height`. The latch is STICKY, since clearing it needs the height back within 100px of a baseline belonging to a display the user is no longer looking at, so it survived until the device was opened again, and rotating any phone hit it too. The shape branch re-baselines instead, which is also what lets a keyboard opened AFTER the fold be detected. ⚠️ `init()` must seed `lastViewportWidth`, or the very first resize reads as a shape change and swallows a real keyboard. ⚠️ **The hinge is a reserved region.** The CSS Viewport Segments media features report two segments only while a foldable is actually bent, and `--fold-inline-end`/`--fold-block-end` (styles.css) measure the strip to keep clear: `0px` on everything else, so the rules are inert by construction rather than by a branch. Codeman's seven centred overlays are all `position: fixed; inset: 0` flex boxes, and each shrinks its CONTENT box with padding rather than the box itself, so the backdrop still covers the far side of the fold and still swallows taps there. ⚠️ Each rule RE-STATES the overlay's own gutter (a later `padding-right` longhand beats the earlier `padding` shorthand it composes with), and the palette needs the compound `.modal.command-palette-modal` because mobile.css loads later and pads it with a shorthand under 768px. `test/foldable-layout.test.ts` DERIVES the overlay list from the stylesheet and compares both numbers, so a new overlay or a moved gutter fails there instead of on hardware nobody has. ⚠️ Physical sides, not logical ones: dialogs sit in the LEFT segment (the TOP one in tabletop pose) in every language, because the HIG keeps Duo's side controls on the same physical edge in RTL. ⚠️ **With the keyboard up, a shape change baselines to `window.innerHeight`** (the layout viewport, keyboard-free on both engines), never the shrunk visual height: the shrunk baseline made the settle event that follows every rotation or fold read as the keyboard closing, and the layout could not recover, since no further drop could re-arm the show branch. ⚠️ **A base gutter that a LATER `@media` block overrides needs its own fold restatement in that block, on a ZERO base**: the phone-width path picker and path preview drop to `padding: 0` under 600px, and the unconditional fold rules at the end of the file put 16px and 18px back on every phone (measured at 393 and 500). The palette's compound rule lives INSIDE the 430-768px band whose mobile.css shorthand it composes with, because outside it there is no side gutter and the addition pushed the shell 6px off centre; the response viewer's tabletop cap has a twin at the end of mobile.css, whose 430px block otherwise outranks it. `test/foldable-layout.test.ts` simulates the cascade across BOTH stylesheets at every breakpoint, with and without the fold rules, so a moved gutter or an unscoped composition fails there rather than on hardware nobody has. Profiles: `iPhone Duo (outer)` / `iPhone Duo (inner)` in `test/mobile/devices.ts`; unit coverage in `test/viewport-shape-change.test.ts`. → [architecture-invariants#folding-devices](docs/architecture-invariants.md#folding-devices) **WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](docs/architecture-invariants.md#webgl-renderer-toggle) @@ -433,7 +433,7 @@ Raw `npx vitest` skips the config (and with it `setup.ts`); always use `npm test **Testing against the live instance**: prod is HTTPS-only on :3000 (`curl -sk https://localhost:3000/...`). ⚠️ `w1`/`w2`/`w3` are the user's REAL sessions — never send input to them. Create your own throwaway session (`POST /api/sessions` then `POST /api/sessions/:id/shell`; creation alone leaves `pid: null` and no pane), test against that, and `DELETE` it by exact id when done. -**Respawn tests**: Use `MockSession` from `test/mocks/index.ts` (defined in `test/mocks/mock-session.ts`). **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (136 device profiles). Browser-testing infra and practices: `docs/browser-testing-guide.md`. +**Respawn tests**: Use `MockSession` from `test/mocks/index.ts` (defined in `test/mocks/mock-session.ts`). **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (138 device profiles). Browser-testing infra and practices: `docs/browser-testing-guide.md`. ## Debugging diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index f42b844d4..3ccb8e7a9 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -398,6 +398,14 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se **Foldable settings identity**: responsive layout remains width-driven through `MobileDetection.getDeviceType()`, but the localStorage namespace/defaults use `MobileDetection.isHandheldDevice()` so an Android foldable keeps `codeman-app-settings-mobile` after unfolding past the desktop breakpoint. The stable handheld check prefers explicit phone/tablet/desktop UA tokens, then `navigator.userAgentData.mobile`; Android WebView is covered by the `Mobile` UA fallback. Do not switch per-device settings namespaces from instantaneous viewport width — a posture-triggered WebView reload would lose opt-in UI such as `showResponseViewer` and `extendedKeyboardBar`. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. +### Folding devices + +**A fold is not a keyboard.** `KeyboardHandler.handleViewportResize()` (mobile-handlers.js) used to read any visual-viewport height drop over 150px as the keyboard appearing. A virtual keyboard only ever takes HEIGHT, so a resize that changes the WIDTH is the device changing shape (a rotation, or a foldable opening or closing) and is skipped by both detection branches. Closing an iPhone Duo (626→466pt wide, 890→678pt tall) otherwise latched `keyboardVisible` with no keyboard on screen: the accessory bar appeared, `main` grew 84px of dead padding, and `updateAppHeight()`, which bails while the keyboard is up, stopped refreshing `--app-height`. The latch was sticky because clearing it needed the height back within 100px of a baseline belonging to a display the user was no longer looking at. The shape branch re-baselines instead, which is also what lets a keyboard opened after the fold be detected, and `init()` seeds `lastViewportWidth` so the first resize of the page is not itself read as a shape change. + +**With the keyboard up, the new baseline is `window.innerHeight`, never the shrunk visual height.** The page sets no `interactive-widget`, so the default resizes-visual mode holds on both engines: the keyboard shrinks only the visual viewport and the layout viewport stays the display's full height (`updateLayoutForKeyboard()` relies on the same fact). The first version baselined to the shrunk height, which made `heightDiff` 0, so the settle event the OS animation fires at the new width (or any later address-bar drift) satisfied the hide branch and ran `onKeyboardHide()` with the keyboard still on screen: accessory bar hidden, the toolbar lift dropped, `main`'s padding cleared. It could not recover, because no further 150px drop can re-arm the show branch against a baseline already sitting at the shrunk height. Reproduced against the real handler in the vm harness with both the fold flavour (626x590 → 466x378 → 466x378) and the rotation flavour (393x359 → 852x150 → 852x160); `test/viewport-shape-change.test.ts` models the two heights separately and pins both. + +**The hinge is a reserved region, and the CSS is inert by construction.** The CSS Viewport Segments media features report two segments only while a foldable is actually bent; `--fold-inline-end` / `--fold-block-end` (end of styles.css) measure the strip to keep clear from the LEADING segment (`env(viewport-segment-right 0 0)` and `env(viewport-segment-bottom 0 0)`, physical sides in every text direction) and are `0px` on everything else. The seven centred overlays are all `position: fixed; inset: 0` flex boxes; each shrinks its CONTENT box with padding rather than the box itself, so the backdrop still covers the far side of the fold and still swallows taps there. The cascade traps, each measured in headless Chromium against styles.css + mobile.css in index.html link order: (1) a later `padding-right` longhand beats the earlier `padding` shorthand it composes with, so each fold rule re-states the overlay's own gutter; (2) a base gutter that a LATER `@media` block overrides needs its own fold restatement in that block on a ZERO base, since the unconditional rules at the end of the file otherwise put the gutter back (the phone path picker and path preview drop to `padding: 0` under 600px and came back at 16px and 18px on every phone); (3) a compound rule written to outrank a mobile.css shorthand must be scoped to the band where that shorthand applies, because outside it there is no gutter to compose with (the palette's `.modal.command-palette-modal` added 0.75rem at 393, 900 and 1400px and pushed the shell 6px off centre, and inside the band it lost its bottom gutter to the shorthand until it restated that side too); (4) mobile.css loads AFTER styles.css, so a same-specificity rule there wins under its own breakpoint (the response viewer's `max-height: 92dvh` at 430px beat the tabletop cap, which now has a twin at the end of mobile.css). `test/foldable-layout.test.ts` DERIVES the overlay list from the stylesheet, simulates the cascade across both files at every breakpoint with and without the fold rules, and requires the two results to differ by exactly the fold strip, so each of the four fails there instead of on hardware nobody has. + ## Security layers ### Layer-by-layer detail diff --git a/test/mobile/README.md b/test/mobile/README.md index c10f8db64..8c9098d24 100644 --- a/test/mobile/README.md +++ b/test/mobile/README.md @@ -2,11 +2,11 @@ Comprehensive mobile UI testing for Codeman's web interface using Playwright with dual-engine support (Chromium + WebKit). -**326 tests across 136 devices — all passing.** +**326 tests across 138 devices — all passing.** ## Purpose -Validates Codeman's mobile UI across 136 devices, covering: +Validates Codeman's mobile UI across 138 devices, covering: - **Keyboard simulation** — 3-layer approach to emulate virtual keyboards in headless browsers - **Touch/swipe interactions** — CDP trusted events (Chromium) + synthetic fallback (WebKit) @@ -34,7 +34,7 @@ npm run test:mobile -- test/mobile/keyboard.test.ts # Quick mode: 6 representative devices, skip full matrix CI_QUICK=1 npm run test:mobile -# Full device matrix only (136 devices) +# Full device matrix only (138 devices) npm run test:mobile -- test/mobile/device-matrix.test.ts # Update visual baselines (delete old baselines, re-run) @@ -51,7 +51,7 @@ npm run test:mobile -- test/mobile/visual-regression.test.ts | `subagent-windows.test.ts` | 3202 | Mobile subagent card dimensions, stacking, interactions | | `settings.test.ts` | 3203 | Settings modal, mobile defaults, persistence | | `layout.test.ts` | 3204 | General mobile layout, fixed elements, device classes | -| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (136 devices) | +| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (138 devices) | | `visual-regression.test.ts` | 3206 | Screenshot comparison at key breakpoints | | `accessibility.test.ts` | 3207 | WCAG touch targets, zoom, focus, ARIA | @@ -66,7 +66,7 @@ npm run test:mobile -- test/mobile/visual-regression.test.ts | standard-tablet | 768–834px | ~8 | iPad Mini | | large-tablet | 835px+ | ~5 | iPad Pro 11" | -136 devices are defined in `devices.ts` — 68 from Playwright's built-in device profiles plus 68 custom entries for newer devices (iPhone 16/17, Pixel 9, Galaxy S25, OPPO Find N5 unfolded, iPad Air M2, Surface Pro, etc.). +138 devices are defined in `devices.ts` — 68 from Playwright's built-in device profiles plus 70 custom entries for newer devices (iPhone 16/17, iPhone Duo in both postures, Pixel 9, Galaxy S25, OPPO Find N5 unfolded, iPad Air M2, Surface Pro, etc.). ### How Devices Are Differentiated @@ -106,7 +106,7 @@ Test File ├─ helpers/touch-sim.ts → CDP trusted touch / synthetic fallback ├─ helpers/assertions.ts → Layout, CSS, accessibility assertions ├─ helpers/visual.ts → pixelmatch screenshot comparison - └─ devices.ts → 136-device registry + └─ devices.ts → 138-device registry ``` ### Keyboard Simulation — 3-Layer Approach From 21dcec5d24f6bc2de633c5dcc2cc1f844cc70cbc Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 14 Sep 2026 16:02:02 +0200 Subject: [PATCH 5/5] test(mobile): follow the 600px phone cut on the Duo branch Rebased over #390, which moved the phone tier's cutoff from 430px to 600px. The palette's compound fold rule now lives in the 600-768px band mobile.css pads, the cascade samples the palette inside that band, and the closed iPhone Duo (466pt) is a phone rather than a small tablet while the open one (626pt) stays a tablet. Comments in both stylesheets, the device registry, CLAUDE.md and architecture-invariants say 600. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- src/web/public/mobile.css | 2 +- src/web/public/styles.css | 4 ++-- test/foldable-layout.test.ts | 25 +++++++++++++------------ test/mobile/devices.ts | 10 +++++----- 6 files changed, 23 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a0d78ada4..93223321c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -326,7 +326,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Foldable settings identity**: responsive layout is width-driven via `MobileDetection.getDeviceType()`, but the localStorage namespace uses `MobileDetection.isHandheldDevice()` so an unfolded Android foldable keeps `codeman-app-settings-mobile`. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. → [architecture-invariants#foldable-settings-identity](docs/architecture-invariants.md#foldable-settings-identity) -**Folding devices: a fold is not a keyboard, and dialogs avoid the hinge** (Apple's [Designing for iPhone Duo](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo)): ⚠️ **A visual-viewport resize that changes the WIDTH is the device changing shape** (a rotation, or a foldable opening or closing) **and is never the virtual keyboard**, which only ever takes height. `handleViewportResize()` read any height drop over 150px as the keyboard appearing, so closing an iPhone Duo (626→466pt wide, 890→678pt tall) latched `keyboardVisible` with no keyboard on screen: the accessory bar appeared, `main` grew 84px of dead padding, and `updateAppHeight()` (which bails while the keyboard is up) stopped refreshing `--app-height`. The latch is STICKY, since clearing it needs the height back within 100px of a baseline belonging to a display the user is no longer looking at, so it survived until the device was opened again, and rotating any phone hit it too. The shape branch re-baselines instead, which is also what lets a keyboard opened AFTER the fold be detected. ⚠️ `init()` must seed `lastViewportWidth`, or the very first resize reads as a shape change and swallows a real keyboard. ⚠️ **The hinge is a reserved region.** The CSS Viewport Segments media features report two segments only while a foldable is actually bent, and `--fold-inline-end`/`--fold-block-end` (styles.css) measure the strip to keep clear: `0px` on everything else, so the rules are inert by construction rather than by a branch. Codeman's seven centred overlays are all `position: fixed; inset: 0` flex boxes, and each shrinks its CONTENT box with padding rather than the box itself, so the backdrop still covers the far side of the fold and still swallows taps there. ⚠️ Each rule RE-STATES the overlay's own gutter (a later `padding-right` longhand beats the earlier `padding` shorthand it composes with), and the palette needs the compound `.modal.command-palette-modal` because mobile.css loads later and pads it with a shorthand under 768px. `test/foldable-layout.test.ts` DERIVES the overlay list from the stylesheet and compares both numbers, so a new overlay or a moved gutter fails there instead of on hardware nobody has. ⚠️ Physical sides, not logical ones: dialogs sit in the LEFT segment (the TOP one in tabletop pose) in every language, because the HIG keeps Duo's side controls on the same physical edge in RTL. ⚠️ **With the keyboard up, a shape change baselines to `window.innerHeight`** (the layout viewport, keyboard-free on both engines), never the shrunk visual height: the shrunk baseline made the settle event that follows every rotation or fold read as the keyboard closing, and the layout could not recover, since no further drop could re-arm the show branch. ⚠️ **A base gutter that a LATER `@media` block overrides needs its own fold restatement in that block, on a ZERO base**: the phone-width path picker and path preview drop to `padding: 0` under 600px, and the unconditional fold rules at the end of the file put 16px and 18px back on every phone (measured at 393 and 500). The palette's compound rule lives INSIDE the 430-768px band whose mobile.css shorthand it composes with, because outside it there is no side gutter and the addition pushed the shell 6px off centre; the response viewer's tabletop cap has a twin at the end of mobile.css, whose 430px block otherwise outranks it. `test/foldable-layout.test.ts` simulates the cascade across BOTH stylesheets at every breakpoint, with and without the fold rules, so a moved gutter or an unscoped composition fails there rather than on hardware nobody has. Profiles: `iPhone Duo (outer)` / `iPhone Duo (inner)` in `test/mobile/devices.ts`; unit coverage in `test/viewport-shape-change.test.ts`. → [architecture-invariants#folding-devices](docs/architecture-invariants.md#folding-devices) +**Folding devices: a fold is not a keyboard, and dialogs avoid the hinge** (Apple's [Designing for iPhone Duo](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo)): ⚠️ **A visual-viewport resize that changes the WIDTH is the device changing shape** (a rotation, or a foldable opening or closing) **and is never the virtual keyboard**, which only ever takes height. `handleViewportResize()` read any height drop over 150px as the keyboard appearing, so closing an iPhone Duo (626→466pt wide, 890→678pt tall) latched `keyboardVisible` with no keyboard on screen: the accessory bar appeared, `main` grew 84px of dead padding, and `updateAppHeight()` (which bails while the keyboard is up) stopped refreshing `--app-height`. The latch is STICKY, since clearing it needs the height back within 100px of a baseline belonging to a display the user is no longer looking at, so it survived until the device was opened again, and rotating any phone hit it too. The shape branch re-baselines instead, which is also what lets a keyboard opened AFTER the fold be detected. ⚠️ `init()` must seed `lastViewportWidth`, or the very first resize reads as a shape change and swallows a real keyboard. ⚠️ **The hinge is a reserved region.** The CSS Viewport Segments media features report two segments only while a foldable is actually bent, and `--fold-inline-end`/`--fold-block-end` (styles.css) measure the strip to keep clear: `0px` on everything else, so the rules are inert by construction rather than by a branch. Codeman's seven centred overlays are all `position: fixed; inset: 0` flex boxes, and each shrinks its CONTENT box with padding rather than the box itself, so the backdrop still covers the far side of the fold and still swallows taps there. ⚠️ Each rule RE-STATES the overlay's own gutter (a later `padding-right` longhand beats the earlier `padding` shorthand it composes with), and the palette needs the compound `.modal.command-palette-modal` because mobile.css loads later and pads it with a shorthand under 768px. `test/foldable-layout.test.ts` DERIVES the overlay list from the stylesheet and compares both numbers, so a new overlay or a moved gutter fails there instead of on hardware nobody has. ⚠️ Physical sides, not logical ones: dialogs sit in the LEFT segment (the TOP one in tabletop pose) in every language, because the HIG keeps Duo's side controls on the same physical edge in RTL. ⚠️ **With the keyboard up, a shape change baselines to `window.innerHeight`** (the layout viewport, keyboard-free on both engines), never the shrunk visual height: the shrunk baseline made the settle event that follows every rotation or fold read as the keyboard closing, and the layout could not recover, since no further drop could re-arm the show branch. ⚠️ **A base gutter that a LATER `@media` block overrides needs its own fold restatement in that block, on a ZERO base**: the phone-width path picker and path preview drop to `padding: 0` under 600px, and the unconditional fold rules at the end of the file put 16px and 18px back on every phone (measured at 393 and 500). The palette's compound rule lives INSIDE the 600-768px band whose mobile.css shorthand it composes with, because outside it there is no side gutter and the addition pushed the shell 6px off centre; the response viewer's tabletop cap has a twin at the end of mobile.css, whose phone block (under 600px) otherwise outranks it. `test/foldable-layout.test.ts` simulates the cascade across BOTH stylesheets at every breakpoint, with and without the fold rules, so a moved gutter or an unscoped composition fails there rather than on hardware nobody has. Profiles: `iPhone Duo (outer)` / `iPhone Duo (inner)` in `test/mobile/devices.ts`; unit coverage in `test/viewport-shape-change.test.ts`. → [architecture-invariants#folding-devices](docs/architecture-invariants.md#folding-devices) **WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](docs/architecture-invariants.md#webgl-renderer-toggle) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 3ccb8e7a9..74c084ef0 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -404,7 +404,7 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se **With the keyboard up, the new baseline is `window.innerHeight`, never the shrunk visual height.** The page sets no `interactive-widget`, so the default resizes-visual mode holds on both engines: the keyboard shrinks only the visual viewport and the layout viewport stays the display's full height (`updateLayoutForKeyboard()` relies on the same fact). The first version baselined to the shrunk height, which made `heightDiff` 0, so the settle event the OS animation fires at the new width (or any later address-bar drift) satisfied the hide branch and ran `onKeyboardHide()` with the keyboard still on screen: accessory bar hidden, the toolbar lift dropped, `main`'s padding cleared. It could not recover, because no further 150px drop can re-arm the show branch against a baseline already sitting at the shrunk height. Reproduced against the real handler in the vm harness with both the fold flavour (626x590 → 466x378 → 466x378) and the rotation flavour (393x359 → 852x150 → 852x160); `test/viewport-shape-change.test.ts` models the two heights separately and pins both. -**The hinge is a reserved region, and the CSS is inert by construction.** The CSS Viewport Segments media features report two segments only while a foldable is actually bent; `--fold-inline-end` / `--fold-block-end` (end of styles.css) measure the strip to keep clear from the LEADING segment (`env(viewport-segment-right 0 0)` and `env(viewport-segment-bottom 0 0)`, physical sides in every text direction) and are `0px` on everything else. The seven centred overlays are all `position: fixed; inset: 0` flex boxes; each shrinks its CONTENT box with padding rather than the box itself, so the backdrop still covers the far side of the fold and still swallows taps there. The cascade traps, each measured in headless Chromium against styles.css + mobile.css in index.html link order: (1) a later `padding-right` longhand beats the earlier `padding` shorthand it composes with, so each fold rule re-states the overlay's own gutter; (2) a base gutter that a LATER `@media` block overrides needs its own fold restatement in that block on a ZERO base, since the unconditional rules at the end of the file otherwise put the gutter back (the phone path picker and path preview drop to `padding: 0` under 600px and came back at 16px and 18px on every phone); (3) a compound rule written to outrank a mobile.css shorthand must be scoped to the band where that shorthand applies, because outside it there is no gutter to compose with (the palette's `.modal.command-palette-modal` added 0.75rem at 393, 900 and 1400px and pushed the shell 6px off centre, and inside the band it lost its bottom gutter to the shorthand until it restated that side too); (4) mobile.css loads AFTER styles.css, so a same-specificity rule there wins under its own breakpoint (the response viewer's `max-height: 92dvh` at 430px beat the tabletop cap, which now has a twin at the end of mobile.css). `test/foldable-layout.test.ts` DERIVES the overlay list from the stylesheet, simulates the cascade across both files at every breakpoint with and without the fold rules, and requires the two results to differ by exactly the fold strip, so each of the four fails there instead of on hardware nobody has. +**The hinge is a reserved region, and the CSS is inert by construction.** The CSS Viewport Segments media features report two segments only while a foldable is actually bent; `--fold-inline-end` / `--fold-block-end` (end of styles.css) measure the strip to keep clear from the LEADING segment (`env(viewport-segment-right 0 0)` and `env(viewport-segment-bottom 0 0)`, physical sides in every text direction) and are `0px` on everything else. The seven centred overlays are all `position: fixed; inset: 0` flex boxes; each shrinks its CONTENT box with padding rather than the box itself, so the backdrop still covers the far side of the fold and still swallows taps there. The cascade traps, each measured in headless Chromium against styles.css + mobile.css in index.html link order: (1) a later `padding-right` longhand beats the earlier `padding` shorthand it composes with, so each fold rule re-states the overlay's own gutter; (2) a base gutter that a LATER `@media` block overrides needs its own fold restatement in that block on a ZERO base, since the unconditional rules at the end of the file otherwise put the gutter back (the phone path picker and path preview drop to `padding: 0` under 600px and came back at 16px and 18px on every phone); (3) a compound rule written to outrank a mobile.css shorthand must be scoped to the band where that shorthand applies, because outside it there is no gutter to compose with (the palette's `.modal.command-palette-modal` added 0.75rem at 393, 900 and 1400px and pushed the shell 6px off centre, and inside the band it lost its bottom gutter to the shorthand until it restated that side too); (4) mobile.css loads AFTER styles.css, so a same-specificity rule there wins under its own breakpoint (the response viewer's `max-height: 92dvh` under 600px beat the tabletop cap, which now has a twin at the end of mobile.css). `test/foldable-layout.test.ts` DERIVES the overlay list from the stylesheet, simulates the cascade across both files at every breakpoint with and without the fold rules, and requires the two results to differ by exactly the fold strip, so each of the four fails there instead of on hardware nobody has. ## Security layers diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index fc1bf2197..9be29153c 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -3853,7 +3853,7 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close { /* Folding devices, tabletop pose: cap the response viewer to the bottom segment. Twin of the rule at the end of styles.css, needed here because the - 430px block above sets `max-height: 92dvh` on the same selector at the same + phone block (under 600px) above sets `max-height: 92dvh` on the same selector at the same specificity, and this file loads later, so the styles.css copy loses on a phone-width foldable. Keep the value identical; test/foldable-layout.test.ts compares them. */ diff --git a/src/web/public/styles.css b/src/web/public/styles.css index ddd9668c7..2dcf6d15d 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -18128,14 +18128,14 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle } /* The palette is a .modal, so the generic rule above covers it everywhere - EXCEPT the 430-768px band, where mobile.css (loaded after this file) pads + EXCEPT the 600-768px band, where mobile.css (loaded after this file) pads it with the SHORTHAND `10vh 0.75rem 0`: that shorthand beats the generic rule on both sides, so this compound rule (0,2,0) restates that band's own gutters, 0.75rem at the side and none at the bottom, plus the fold strips. Scoped to the same band on purpose: unscoped, it ADDED 0.75rem on every other width, where the palette has no side gutter to compose with, and pushed the shell 6px off centre (measured at 393, 900 and 1400). */ -@media (max-width: 768px) and (min-width: 430px) { +@media (max-width: 768px) and (min-width: 600px) { .modal.command-palette-modal { padding-right: calc(0.75rem + var(--fold-inline-end)); padding-bottom: var(--fold-block-end); diff --git a/test/foldable-layout.test.ts b/test/foldable-layout.test.ts index ba6280810..fd6bd7b37 100644 --- a/test/foldable-layout.test.ts +++ b/test/foldable-layout.test.ts @@ -22,8 +22,8 @@ * 3. The gutter an overlay ends up with is a CASCADE across two files and * several breakpoints, not one rule: a later @media block can zero it (the * phone path picker under 600px), mobile.css can replace it with a - * shorthand (the palette between 430 and 768px) and, loading later, can - * outrank a same-specificity rule (the response viewer under 430px). So the + * shorthand (the palette between 600 and 768px) and, loading later, can + * outrank a same-specificity rule (the response viewer under 600px). So the * cascade is simulated at every breakpoint, once with the fold rules and * once without, and the two results must differ by exactly the fold strip. * Each of the three shipped once with the top-level-only comparison green. @@ -325,15 +325,15 @@ describe('fold reserved region: every centred overlay is covered', () => { // Chromium (styles.css + mobile.css in index.html link order): the phone // path picker is flush under 600px and keeps its 16px gutter above it; // the palette carries mobile.css's 0.75rem side gutter only inside the - // 430-768px band. A model that cannot reproduce these numbers proves + // 600-768px band. A model that cannot reproduce these numbers proves // nothing about the fold rules built on top of them. const picker = ['path-picker-overlay']; expect(cascadedPadding(picker, 'right', 393, false)).toBe('0'); expect(cascadedPadding(picker, 'right', 626, false)).toBe('16px'); const palette = ELEMENTS.at(-1)!.classes; expect(cascadedPadding(palette, 'right', 393, false)).toBeNull(); - expect(cascadedPadding(palette, 'right', 500, false)).toBe('0.75rem'); - expect(cascadedPadding(palette, 'bottom', 500, false)).toBe('0'); + expect(cascadedPadding(palette, 'right', 626, false)).toBe('0.75rem'); + expect(cascadedPadding(palette, 'bottom', 626, false)).toBe('0'); expect(cascadedPadding(palette, 'right', 900, false)).toBeNull(); }); @@ -353,7 +353,7 @@ describe('fold reserved region: every centred overlay is covered', () => { it('composes with the padding shorthand mobile.css gives the command palette, inside that band only', () => { // mobile.css loads after styles.css and sets a `padding` SHORTHAND on - // .command-palette-modal between 430 and 768px, exactly where a folding + // .command-palette-modal between 600 and 768px, exactly where a folding // phone lives, so a bare .command-palette-modal rule would lose to it and // the compound rule has to restate BOTH of that band's gutters. Scoped to // the same band: unscoped, it added 0.75rem where the palette has no side @@ -374,7 +374,7 @@ describe('fold reserved region: every centred overlay is covered', () => { it('gives the response viewer cap a later twin in mobile.css', () => { // mobile.css sets `max-height` on .response-viewer at the same specificity - // under 430px and loads later, so the styles.css cap alone loses on a + // under 600px and loads later, so the styles.css cap alone loses on a // phone-width foldable. The twin must come after that rule and carry the // identical value. const capOf = (root: postcss.Root) => @@ -441,11 +441,12 @@ describe('a fold never changes which settings the device is using', () => { expect(detectionFor(postures[2].ua, postures[2].w).getDeviceType()).toBe('mobile'); }); - it('gives both iPhone Duo displays the tablet layout', () => { - // 466 and 626 both sit above the 430px phone cut and below 768. Deliberate - // (see shouldUseMobileOverview), and pinned because a 5.4" phone landing in - // the tablet band is the kind of thing that looks like a bug later. - expect(detectionFor(postures[0].ua, postures[0].w).getDeviceType()).toBe('tablet'); + it('gives the closed iPhone Duo the phone layout and the open one the tablet layout', () => { + // 466 sits under the 600px phone cut (#390 moved it up from 430) and 626 + // above it, below 768. Deliberate (see shouldUseMobileOverview), and pinned + // because the tier flipping under a fold is the kind of thing that looks + // like a bug later: closed, the Duo is a phone; open, it is a small tablet. + expect(detectionFor(postures[0].ua, postures[0].w).getDeviceType()).toBe('mobile'); expect(detectionFor(postures[1].ua, postures[1].w).getDeviceType()).toBe('tablet'); }); }); diff --git a/test/mobile/devices.ts b/test/mobile/devices.ts index 259760e2d..4d2864cda 100644 --- a/test/mobile/devices.ts +++ b/test/mobile/devices.ts @@ -292,11 +292,11 @@ const customEntries: DeviceEntry[] = [ // in both axes. The registry's other foldable (Find N5) uses the full // viewport for the same reason. // - // The PAIR is what earns its place here. Both postures land in the tablet - // band (466 and 626 are each above the 430px phone cut and below 768), so a - // 5.4" phone in someone's hand gets the roomier layout. Deliberate, per the - // note on shouldUseMobileOverview(), and worth a profile precisely because it - // is easy to regress into a phone-width assumption. What must NOT move with + // The PAIR is what earns its place here. The postures straddle the 600px + // phone cut (#390): closed, 466 is a phone; open, 626 is a small tablet, so + // the layout tier flips with the fold. Deliberate, per the note on + // shouldUseMobileOverview(), and worth a profile precisely because it is + // easy to regress into a single-width assumption. What must NOT move with // the fold is the per-device settings identity, which is UA-based and // therefore identical across the two; test/mobile/settings.test.ts pins it. custom('iPhone Duo (outer)', 466, 678, 3, IOS_MOBILE_UA('26_0'), true),