fix(panel): pinch-zoom isn't the keyboard, and Chat topic actions show on phones

Merged
DA
6d ago
+253-17

Two phone bugs from the post-merge review of the web and mobile work, plus a small iOS long-press fix.

What changed

  • (P1) Pinch-zoom was read as the keyboard. use-visual-viewport.ts treated innerHeight − visualViewport.bottom as the keyboard's height. Zooming in shrinks the visual viewport too, so .app-stage re-laid itself into the zoomed box and followed every pan.
    • At scale 1 (visualViewport.scale within 0.01 of 1) nothing changes: the top is offsetTop, the stage height is the visible height, and the keyboard is what lies below it.
    • While zoomed, the top stays where it was last measured at scale 1. The keyboard is read from the viewport's size as it would be unzoomed: bottom = min(W, top + round(height × scale)), --luke-stage-height = bottom − top and --luke-keyboard-inset = W − bottom. W is the window's layout height, described next.
    • W while zoomed is the root's clientHeight, the initial containing block. No engine resizes it under pinch (interop-2022 explainers/icb.md). That matters because WebKit's innerHeight does follow the pinch (WebKit bug 174362). It is also the stage's real containing block, since .app-stage is absolutely positioned under an unpositioned root. Case by case:
      • Android with resizes-content: the ICB shrinks with the keyboard, so the inset is 0, which is right because the page itself resized.
      • iOS: the keyboard overlays the page, so the inset is the keyboard's height.
      • Toolbar changes: clamped by the min.
      • Desktop trackpad pinch: nothing moves.
    • At scale 1, innerHeight stays the reference, exactly as before. I kept it on purpose rather than switching to clientHeight. On iOS the ICB is the small viewport, measured with the toolbars shown. With the toolbars hidden, the visible area is taller than clientHeight, so a keyboard inset measured against it would come out short by the toolbars' height.
    • Why a pinch or a pan moves nothing: visualViewport.height is the layout height divided by the scale, so height × scale stays at the layout height however far you zoom, and clientHeight doesn't follow the pinch in any engine. Panning changes only offsetTop, which the zoomed branch ignores. Rounding keeps the fractions of a pinch from shifting the stage by a pixel.
    • Why read the keyboard at all while zoomed: the keyboard covers part of the screen, so it removes K from height × scale at any scale. A keyboard opened or closed while zoomed still moves the composer. This matters because iOS often zooms in on a field as it focuses it. The brief's literal "keep the last values" left the composer behind the keyboard in that case. Devin's review raised it, and the orchestrator approved this change.
    • The stage's top stays where it was last measured at scale 1.
    • The keyboard is read as if the page were not zoomed. visualViewport.height × scale is the height of the box that is seen, in CSS pixels at scale 1. So the stage's bottom is min(innerHeight, top + round(height × scale)), and the keyboard inset is innerHeight − bottom.
    • Why a pinch or a pan still moves nothing: a pinch changes height and scale in inverse proportion, so their product stays innerHeight (rounded to a whole pixel, so a pinch's fractions don't shift it). A pan changes only offsetTop, which is ignored while zoomed. Only a keyboard changes the product. It takes its height off the visible area at any scale, so it still lifts the composer when it opens while zoomed, as happens when iOS auto-zooms on a focused field. This was added after Devin's review; keeping the last values unchanged left the composer behind such a keyboard. The orchestrator approved it.
  • (P1) A Chat topic's actions were unreachable on a phone. The narrow room's .chat-head > h2 ~ * rule hid .chat-head-actions along with the meta line. TopicActions is the only way to edit, archive or bring back a topic. The rule is now .chat-head > h2 ~ :not(.chat-head-actions). At 390px the head fits: back button, then the truncated name, then ⋯ (screenshots below).
  • (P2) Text selection could race a long press on iOS. Under @media (pointer: coarse), [data-long-press] now sets -webkit-user-select: none and user-select: none, next to the unconditional -webkit-touch-callout: none. It is scoped to coarse pointers so a mouse can still drag across a version summary or a member's email in a row to copy it. This was also raised in review and approved. .luke-menu now uses z-index: var(--luke-z-menu) instead of the hard-coded 20. Both profiles set that token to 20, so nothing moves.

At scale 1 nothing changes, on the desktop or on a phone. Desktop browsers can pinch-zoom with a trackpad, and they take the zoomed branch above. The two CSS rules apply only in the narrow room, or to long-press elements under a coarse pointer.

Tests

Each test fails without its fix.

  • use-visual-viewport.test.ts models both engines: Blink, where innerHeight keeps the layout height, and WebKit, where innerWidth/innerHeight shrink with the pinch. jsdom answers clientHeight as 0, so it is stubbed to the fake's layout height. In each engine, zoom and pan leave the stage unchanged, and after the zoom is released the keyboard lifts it. A keyboard opened while zoomed lifts the stage by its unzoomed height, and closing it lets the stage back down. In Blink, a window that shrinks for the keyboard reads no inset, zoomed or not, and gets its height back when the keyboard closes while zoomed.
  • styles/narrow-chat-head.test.ts: a narrow Chat head shows the back button and the topic's menu and hides the purpose; a wide head shows both.
  • styles/touch-controls.test.ts: on a touch screen, an archived agent's <li> (a long-press carrier that isn't a button) has user-select: none. With a mouse, its text can still be selected.

pnpm check:affected exits 0.

Screenshots: the narrow Chat head at 390×844

I rendered the real DesktopChat on the fixture chat in a narrow shell, with the web app's stylesheet, in a throwaway Vite page that isn't committed. Its left padding is the folded sidebar's toggle clearance, as on main.

BeforeAfter
Before: the head shows the back button and the channel name onlyAfter: the head shows the back button, the truncated channel name, and the ⋯ topic menu

Made with Luke


Devin Review