fix(mini-app): make the details sheet a real two-snap bottom sheet - #11
Merged
Merged
Conversation
Web Inspector on a real iPhone: the sheet was 193px tall with a 60px scroll port, so almost every vacancy action sat behind a strip of content. The host gesture was not the cause — PR #10 works, and Telegram receives allow_vertical_swipe:false — and neither was the viewport, which reports 800 everywhere. The cause is sizing. The sheet is a flex column whose only growing child is `flex: 1; min-height: 0`, and it had no height of its own. With `height: auto` that child has a base size of 0 and its content-based minimum switched off, so it contributes nothing to the column's intrinsic height and there is no free space to hand back: the sheet shrink-wraps to grip + header and the body is left at the size of its padding. WebKit does that; Chromium stretches the dialog instead, which is why a Chromium-only suite never saw it — playwright.config.ts documents that the WebKit build segfaults on this machine. So the sheet gets an explicit height, and with a height it may as well be the bottom sheet the grip has always implied: two snap points at 42% and 90% of the *visible* Telegram viewport, the expanded one clamped to clear the host header. The ratios live in CSS, so a viewportChanged event recomputes both through ordinary custom-property substitution with no resize listener. Dragging is local to a `.sheet__handle` region — grip plus header, `touch-action: none` — using pointer events with pointer capture. That is the whole gesture arbitration: a finger that lands on the body is scrolling the body, because nothing else is listening there. No global touchmove interception, no scrollTop-watching handover, no dependency added. The <dialog> is retained. What it was blamed for was the sizing above, not anything about being a dialog, and it still supplies the focus trap, inert background, Esc, focus restore and top-layer stacking that a hand-rolled overlay would have to reimplement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught what this Mac could not: Chromium's synthetic scroll gesture produced no movement at all on the Linux runner — scrollTop stayed at exactly 0 — because the fling it resolves into is asynchronous and gets dropped there. fingerScroll now drives the browser's own gesture recogniser with a paced touch drag, which is what a finger on glass produces, and falls back to the synthetic controller with the fling suppressed. Both are genuine touch input; neither is a wheel or a scrollTop write, so the evidence these tests offer is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pointerup and pointercancel were sharing one handler, so an interrupted gesture committed on whatever distance it happened to have reached — an OS gesture, an incoming call or a stray palm could expand the sheet, collapse it, or dismiss it outright, none of which the user asked for. The teardown is the part they genuinely share, so that moves into releaseDrag() and the two events differ only in what follows: pointerup reads intent from the distance exactly as before, pointercancel reads nothing and lets the sheet spring back to the snap the gesture began at. No change to the snap ratios, the 64px threshold, the CSS, body scrolling or the host vertical-swipe lifecycle. Covered by four tests driven by a real browser-issued pointercancel (CDP touchCancel), which also assert the drag had passed the commit threshold before it was cancelled, so they cannot pass by never having dragged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pashawkola33
marked this pull request as ready for review
August 9, 2026 20:50
Owner
Author
|
Real-device acceptance completed on Telegram iOS before merge. Verified on the real iPhone test bot through a production build served at
Current exact head: The branch was also tested with the narrow Note for future local real-device preview: because the build base is |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What the device evidence actually pointed at
Web Inspector on a real iPhone (Telegram iOS,
WebApp.version9.6) ruled out both previoussuspects:
isVerticalSwipesEnabledisfalsewhile the sheetis open, and Telegram receives
web_app_setup_swipe_behavior { allow_vertical_swipe: false }.innerHeight,visualViewport.height,--tg-viewport-heightand--tg-viewport-stable-heightall report 800.touchstart/move/endreach the sheet,defaultPreventedisfalse, andscrollTopprogresses to ~202 under a real finger.What was left is the geometry the instrumentation printed: a 193px sheet with a 60px scroll
port, against a
maxHeightof 736px. Injecting an explicit height took the body to ~603px.Root cause
The sheet is a flex column whose only growing child is
.sheet__body { flex: 1; min-height: 0 },and the sheet itself had no height — only a
max-height.With
height: auto, that child's flex base size is0%andmin-height: 0switches off itscontent-based minimum, so it contributes nothing to the column's intrinsic height — and an
auto-height container has no free space to distribute back. The sheet therefore shrink-wraps to
grip + header, and the body is left at exactly the size of its own padding. 60px is the
padding:
var(--s4)top pluscalc(var(--s5) + var(--safe-bottom))bottom.WebKit does this. Chromium stretches the dialog to
max-heightinstead. Measured on thisbranch's own harness, Chromium gives a 489px sheet with a 378px body from the same stylesheet.
Why the existing tests missed it
Two independent reasons, and both had to be fixed:
playwright.config.tsdocuments that the WebKit buildsegfaults on this machine (Darwin 25) and substitutes Chromium with touch emulation. The
collapse is WebKit-specific intrinsic sizing, so it was invisible by construction.
a sheet that overflowed the visible viewport — so it asserts containment
(
sheet.bottom <= visible,sheet.top >= safeTop) and scrollability (bodyMaxScroll > 0).A 193px sheet with a 60px port satisfies all of them, comfortably. Nothing anywhere put a
lower bound on how much sheet the user actually gets.
Fix (2) is the durable one: the new spec asserts lower bounds and snap identity, which fail
loudly in any engine. Had those assertions existed, a WebKit run would have caught this. Being
straight about the limit: they still cannot catch it on Chromium, because Chromium does not
reproduce the collapse. What removes the risk is the fix itself — an explicit height takes
intrinsic sizing out of the path entirely, so there is no longer an engine-dependent answer.
Why
<dialog>was retainedThe test applied was the one asked for — does it fight the required behaviour? It does not.
What it was blamed for is the sizing above, which is a CSS shrink-wrap problem that an explicit
height settles; nothing about snap heights, dragging or native body scrolling is impeded by
being in the top layer. Against that, replacing it means hand-rolling focus trap, inert
background, Esc-to-close, focus restore on close, and top-layer stacking — the exact list the
accessibility requirements ask to preserve, reimplemented for no behavioural gain. So the
element stays and the stylesheet changes.
The snap model
Two states. The ratios live in CSS, so a
viewportChangedrecomputes both through ordinarycustom-property substitution — no resize listener to keep in step:
0.42 × visiblemin(0.90 × visible, visible − safeTop − 8px)visible − safeTop − 8px--sheet-vhis the visible Telegram height, never raw100vh; outside Telegram it fallsback to
100dvh. React only flipsdata-snap, and reads the two ratios back out of computedstyle for the drag clamp, so the numbers have exactly one home.
The expanded clamp is why 90% is nominal: on a device reporting a 103px Telegram header, 90%
and clearing the header are not simultaneously satisfiable, and clearing the header wins.
max-heightwas0.92 × vh − safeTop, which double-counted the header; the new form recovers~56px of content in the header case while keeping every PR #6 invariant.
Gesture arbitration
Spatial, not temporal. The sheet is draggable only from
.sheet__handle— grip plus header,touch-action: none. The body is an ordinaryoverflow-y: autocontainer with no handler onit at all.
So "am I dragging the sheet or scrolling its content?" is answered by where the finger landed.
There is no scrollTop-watching handover, no gesture state machine, no
touchmovepreventDefault, nothing global, and no dependency. Requirement "scrolling the body must notcollapse the sheet while
scrollTop > 0" holds by construction rather than by rule.Mechanics: pointer events (
pointerdown/move/up/cancel) withsetPointerCapture, so thegesture stays attached once the finger leaves the handle and there is no document listener to
unwind. A press that lands on a
buttonorais that control's — checked withclosest()—so the close button still closes. Heights are written straight to
--sheet-dragrather thanthrough React state, so a drag is not a render per frame, and
[data-dragging]suspends the260ms height transition so nothing lags behind the finger.
Commit rule: 64px of travel from where the drag began, not the midpoint between snaps. The
midpoint is a quarter of the screen away and would make every state change a deliberate haul
rather than a flick.
same branch, so it did not add a mechanism)
Tests
tests/telegram.ts— the existing emulator, extended, not replaced: a workingBackButtonwith__pressBack, plusfingerDragandfingerScroll. Both drive real browser input — CDPInput.dispatchTouchEventon the touchscreen projects, real pointer input elsewhere. No test inthe new file proves mobile interaction with
element.scrollTop = …ormouse.wheel.fingerScrollneeded a second path, and CI is what found it: Chromium'sInput.synthesizeScrollGesturemoved nothing on the Linux runner —scrollTopstayed atexactly 0 — because the fling it resolves into is asynchronous and gets dropped there, while the
same call scrolls fine on this Mac. It now drives the browser's own gesture recogniser with a
paced touch drag (distinct, increasing timestamps: with every event at one instant the recogniser
sees infinite velocity and declines to call it a scroll), and falls back to the synthetic
controller with
preventFling: true. Both are genuine touch input, so the evidence is unchanged.tests/sheet-snap.spec.ts, 19 tests × 3 browser projects:> 120px,> 40%of the sheet,> 1.5×its own paddingOpen vacancyreached by finger-scroll and clicked['disable']throughout,enableon closescrollTop0Red before green: against the pre-fix source, 16 of 19 fail. The 3 that pass are the
close button, Escape and Telegram Back — paths that already worked, kept as regression guards.
Existing suites unchanged and green: PR #6
sheet.spec.ts(11 × 3) and PR #10swipes.spec.ts(8 × 3). One shared helper changed:
setStableViewportnow settles for 400ms rather than 120ms,because the snap height it drives is now transitioned.
Totals: full Playwright 214 passed (was 157).
npm run lintclean ·npx tsc -bclean ·npm run buildclean ·git diff --checkclean. Maven not run — no backend file is touched.Real-device test before this leaves draft
Playwright being green is not acceptance for this one. The dev server deliberately does not
load the Telegram SDK (
vite.config.tsinjects it onbuildonly), sonpm run devcannot beused for a device test —
window.Telegramwould be absent and the app would fall back to100dvh. Use a production build:Then point a throwaway BotFather test bot's Mini App URL at
https://<tunnel-host>/mini-app/and open it from Telegram iOS. Verified locally that thisbuild serves 200 at
/mini-app/withtelegram-web-app.jsinjected. Alternatively, deploy thisbranch to your staging host and test there.
Acceptance checklist — Telegram iOS
Open vacancyactionFiles changed
mini-app/src/styles/app.css.sheet__handlemini-app/src/features/JobDetails.tsxmini-app/tests/telegram.tsfingerDrag/fingerScroll/centreOf, snap + body-height geometrymini-app/tests/sheet-snap.spec.tsFrontend only. No backend, no P0-B mutation/recovery code, no DB or Flyway, no Docker, Compose
or infrastructure.
NOT DEPLOYED
Production is untouched. Nothing deployed, nothing merged, nothing marked ready.
Draft PR, pending the real-device pass above.