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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,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.
Expand Down
48 changes: 41 additions & 7 deletions src/web/public/mobile-handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) => {
Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand All @@ -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`);
}

Expand Down
103 changes: 103 additions & 0 deletions src/web/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -17817,3 +17817,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));
}
}
Loading