Skip to content

fix(mini-app): make the details sheet a real two-snap bottom sheet - #11

Merged
pashawkola33 merged 3 commits into
mainfrom
fix/mini-app-bottom-sheet-snap
Aug 9, 2026
Merged

pashawkola33 merged 3 commits into
mainfrom
fix/mini-app-bottom-sheet-snap

Conversation

@pashawkola33

@pashawkola33 pashawkola33 commented Aug 9, 2026

Copy link
Copy Markdown
Owner

What the device evidence actually pointed at

Web Inspector on a real iPhone (Telegram iOS, WebApp.version 9.6) ruled out both previous
suspects:

  • Host gesture — not it. PR fix(mini-app): let the details sheet own the vertical gesture on Telegram iOS #10 works: isVerticalSwipesEnabled is false while the sheet
    is open, and Telegram receives web_app_setup_swipe_behavior { allow_vertical_swipe: false }.
  • Viewport — not it. innerHeight, visualViewport.height, --tg-viewport-height and
    --tg-viewport-stable-height all report 800.
  • Touch delivery — not it. touchstart/move/end reach the sheet, defaultPrevented is
    false, and scrollTop progresses 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 maxHeight of 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 is 0% and min-height: 0 switches off its
content-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 plus calc(var(--s5) + var(--safe-bottom)) bottom.

WebKit does this. Chromium stretches the dialog to max-height instead. Measured on this
branch'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:

  1. The suite is Chromium-only. playwright.config.ts documents that the WebKit build
    segfaults on this machine (Darwin 25) and substitutes Chromium with touch emulation. The
    collapse is WebKit-specific intrinsic sizing, so it was invisible by construction.
  2. Every geometry assertion was one-sided. PR fix(mini-app): keep the details sheet inside Telegram's visible viewport #6 was written against the opposite failure —
    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 retained

The 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 viewportChanged recomputes both through ordinary
custom-property substitution — no resize listener to keep in step:

--sheet-vh: var(--tg-viewport-stable-height, 100dvh);
--sheet-collapsed: 0.42;
--sheet-expanded: 0.9;
height: var(--sheet-drag, calc(var(--sheet-vh) * var(--sheet-snap)));
max-height: calc(var(--sheet-vh) - var(--safe-top) - 8px);
Height
collapsed (open) 0.42 × visible
expanded min(0.90 × visible, visible − safeTop − 8px)
ceiling visible − safeTop − 8px

--sheet-vh is the visible Telegram height, never raw 100vh; outside Telegram it falls
back to 100dvh. React only flips data-snap, and reads the two ratios back out of computed
style 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-height was 0.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 ordinary overflow-y: auto container with no handler on
it 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 touchmove
preventDefault, nothing global, and no dependency. Requirement "scrolling the body must not
collapse the sheet while scrollTop > 0" holds by construction rather than by rule.

Mechanics: pointer events (pointerdown/move/up/cancel) with setPointerCapture, so the
gesture stays attached once the finger leaves the handle and there is no document listener to
unwind. A press that lands on a button or a is that control's — checked with closest()
so the close button still closes. Heights are written straight to --sheet-drag rather than
through React state, so a drag is not a render per frame, and [data-dragging] suspends the
260ms 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.

  • collapsed + drag up past 64px → expanded
  • expanded + drag down past 64px → collapsed
  • collapsed + drag down past 64px → dismiss (implemented; it is the same threshold and the
    same branch, so it did not add a mechanism)
  • anything shorter → springs back to the snap it started from

Tests

tests/telegram.ts — the existing emulator, extended, not replaced: a working BackButton with
__pressBack, plus fingerDrag and fingerScroll. Both drive real browser input — CDP
Input.dispatchTouchEvent on the touchscreen projects, real pointer input elsewhere. No test in
the new file proves mobile interaction with element.scrollTop = … or mouse.wheel.

fingerScroll needed a second path, and CI is what found it: Chromium's
Input.synthesizeScrollGesture moved nothing on the Linux runner — scrollTop stayed at
exactly 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:

1 opens at the collapsed snap, bottom-aligned, close control clickable, 40–45% of visible
2 collapsed body is usable> 120px, > 40% of the sheet, > 1.5× its own padding
3 finger-drag the handle up → expanded, taller sheet and taller body
4 expanded respects visible viewport and safe top
5 with no host header, expanded lands in 88–92%
6 finger-drag down → collapsed, sheet still open
7 drag down from collapsed → dismissed
8 body finger-scrolls while the sheet stays expanded at the same height
9 already scrolled, dragging either way inside the body never collapses the sheet
10 final Open vacancy reached by finger-scroll and clicked
11–14 close button · backdrop · Escape (outside Telegram) · Telegram Back
15 dragging does not disturb PR #10's swipe lifecycle: ['disable'] throughout, enable on close
16 reopen starts collapsed with scrollTop 0
17 a host viewport change recomputes both snap points
18 small iPhone (375×667, visible 460, 56px header) usable at both snaps
19 desktop with no Telegram host is sensible at both snaps

Red 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 #10 swipes.spec.ts
(8 × 3). One shared helper changed: setStableViewport now settles for 400ms rather than 120ms,
because the snap height it drives is now transitioned.

Totals: full Playwright 214 passed (was 157). npm run lint clean · npx tsc -b clean ·
npm run build clean · git diff --check clean. 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.ts injects it on build only), so npm run dev cannot be
used for a device test — window.Telegram would be absent and the app would fall back to
100dvh. Use a production build:

cd mini-app
npm run build
npx vite preview --port 4173          # serves the built app at /mini-app/ (base is set for builds)
# expose it over HTTPS — Telegram will not accept a plain-http or LAN URL:
cloudflared tunnel --url http://localhost:4173     # or: ngrok http 4173

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 this
build serves 200 at /mini-app/ with telegram-web-app.js injected. Alternatively, deploy this
branch to your staging host and test there.

Acceptance checklist — Telegram iOS

  • Open Full details — the sheet opens at ~40–45% of the visible viewport, not a 193px strip
  • Header, title and close control are visible without scrolling
  • Drag the grip/header upward — the sheet visibly expands to ~90% (less the Telegram header)
  • Drag it down — it visibly collapses back to the initial snap
  • Expand it, then scroll long content with a finger — content scrolls
  • The sheet stays expanded while the content scrolls
  • Reach and tap the bottom Open vacancy action
  • Drag down from the collapsed snap — the sheet dismisses
  • Close the sheet — Telegram's normal vertical host gesture works again
  • While the sheet is open, the Telegram header still minimises/closes the app

Files changed

File Change
mini-app/src/styles/app.css explicit snap-driven height, snap ratios, expanded clamp, drag suspension, .sheet__handle
mini-app/src/features/JobDetails.tsx snap state, pointer drag on the handle, handle wrapper markup
mini-app/tests/telegram.ts working BackButton, fingerDrag/fingerScroll/centreOf, snap + body-height geometry
mini-app/tests/sheet-snap.spec.ts new — the 19 tests above

Frontend 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.

pashawkola33 and others added 3 commits August 9, 2026 22:14
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
pashawkola33 marked this pull request as ready for review August 9, 2026 20:50

Copy link
Copy Markdown
Owner Author

Real-device acceptance completed on Telegram iOS before merge.

Verified on the real iPhone test bot through a production build served at /mini-app/ over HTTPS:

  • opens at the collapsed snap instead of the 193px strip
  • drag handle up expands the sheet
  • drag down collapses it
  • long body content finger-scrolls while the sheet remains expanded
  • Open vacancy is reachable
  • collapsed drag-down dismiss works
  • Close and Telegram Back work
  • Telegram host vertical swipe is restored after close

Current exact head: 3bda742557d18504f14b8542e1bbd83bea68ce06.
Exact-head CI run 31332540749 is green: 222 passed / 4 skipped. The 4 skips are the real-pointercancel cases on the non-touch desktop project.

The branch was also tested with the narrow pointercancel fix: interrupted drags tear down transient drag state but never commit expand/collapse/dismiss.

Note for future local real-device preview: because the build base is /mini-app/, run preview with --base /mini-app/ when using vite preview, e.g. npx vite preview --base /mini-app/ --port 4173 --strictPort.

@pashawkola33
pashawkola33 merged commit 746ca57 into main Aug 9, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant