feat(stream-header): free-range curtain drag through full pin↔closed↔peek range with bottomPinned-aware body bail and native-only handle

This commit is contained in:
v.lagerev 2026-05-20 00:26:10 +03:00
parent 2617eaf46e
commit 16cb9c5b26
4 changed files with 263 additions and 136 deletions

View file

@ -129,20 +129,24 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
// Two parallel curtain-gesture surfaces:
//
// * `useCurtainHandleGesture` — the dedicated 32 px drag-handle
// at the top of the curtain. Crisp 1:1 finger ↔ curtain on
// every transition (pin, unpin, peek, close-peek, form-close).
// at the top of the curtain. Crisp 1:1 finger ↔ curtain. From
// closed the gesture is a free-range drag spanning pin↔closed↔
// peek in one motion (`closed-free`); other snaps drive single-
// destination transitions (unpin / close-peek / form-close).
// Engages regardless of whether the chat list is scrollable —
// the handle is a distinct surface and never competes with list
// scroll.
// scroll. Only rendered on native (`isNativePlatform()`).
//
// * `useCurtainBodyGesture` — anywhere on the curtain body
// OUTSIDE the handle (chat list, empty-state placeholder,
// bottom-pinned row). Rubber-banded (0.65) on every transition
// so the body drag reads as physically «heavier» than the
// handle's crisp pull. Engages ONLY when the chat list has no
// scrollable content — long lists keep native vertical scroll;
// short / empty lists let the user pull the curtain «from
// anywhere».
// OUTSIDE the handle (chat list, empty-state placeholder).
// Rubber-banded (0.65) for all transitions, so the body drag
// reads as physically «heavier» than the handle's crisp pull.
// Engages only when the chat list has no scrollable content;
// additionally bails on touches that start inside the bottom-
// pinned slot (DirectSelfRow / WorkspaceFooter / ChannelCreate
// have their own drag-to-open bottom sheets) and on touches
// that start while pinned (unpin is HANDLE-only — the user has
// to grab the dedicated affordance to release the lock).
//
// Both hooks share `handleVisual` (mirrors desktop
// `PageNavResizeHandle`: `dragging` lights up the grabber pill;
@ -154,6 +158,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
// visual.
const handleRef = useRef<HTMLDivElement>(null);
const curtainRef = useRef<HTMLDivElement>(null);
const bottomPinnedRef = useRef<HTMLDivElement>(null);
const [handleVisual, setHandleVisual] = useState<{ dragging: boolean; atCommit: boolean }>({
dragging: false,
atCommit: false,
@ -171,6 +176,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
useCurtainBodyGesture({
curtainRef,
handleRef,
bottomPinnedRef,
scrollRef,
snap: curtain.snap,
pinned: curtain.pinned,
@ -448,14 +454,17 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
}}
onTransitionEnd={onCurtainTransitionEnd}
>
{/* Drag handle (native-only behaviour, but rendered on all
platforms so the layout stays identical the gesture hook
short-circuits off-native). Hosts the entire curtain
gesture surface pin, unpin, peek, close-peek and
form-close all bind here, leaving the chat list to native
scroll. Stays mounted across snap transitions so the
gesture surface is always reachable when there is one to
make.
{/* Drag handle native-only. On web (desktop browsers,
Electron) the curtain has no interactive snap states, so
the handle would be pure decoration with no behaviour
behind it; rendering it conditionally drops the 32 px
grabber strip on those surfaces and lets the chat list
sit flush against the curtain's rounded top.
On native the handle hosts the authoritative curtain
gesture (pin / unpin / peek / close-peek / form-close)
and stays mounted across snap transitions so the gesture
surface is always reachable when there is one to make.
`data-dragging` / `data-at-commit` mirror the desktop
`PageNavResizeHandle`: CSS selectors on `handleBar` light
@ -463,15 +472,17 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
Both attrs are emitted/cleared only via React state set by
the gesture hook (dedup'd), so the handle visual updates
without slamming the DOM on every touchmove. */}
<div
ref={handleRef}
className={css.handle}
data-dragging={handleVisual.dragging || undefined}
data-at-commit={handleVisual.atCommit || undefined}
aria-hidden
>
<div className={css.handleBar} />
</div>
{isNativePlatform() && (
<div
ref={handleRef}
className={css.handle}
data-dragging={handleVisual.dragging || undefined}
data-at-commit={handleVisual.atCommit || undefined}
aria-hidden
>
<div className={css.handleBar} />
</div>
)}
{children}
{/* `bottomPinned` (DirectSelfRow, ChannelCreateRow, etc.) is
kept mounted across snaps so the curtain reads as a self-
@ -482,6 +493,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
`keyboardOpen` effect above for the rationale). */}
{bottomPinned && (
<div
ref={bottomPinnedRef}
className={css.bottomPinnedSlot}
style={keyboardOpen ? { height: 0, overflow: 'hidden' } : undefined}
>

View file

@ -97,30 +97,43 @@ export const PIN_TRAVEL_PX = TABS_ROW_PX;
// release for the snap to flip. Anything shorter reads as accidental
// and springs back to the previous resting snap.
//
// With 1:1 finger ↔ curtain tracking (no rubber-band on pin / unpin —
// see `useCurtainHandleGesture`), the committing finger pull is
// On the handle the up direction is 1:1 with no upper clamp (the
// «closed-free» transition spans the full pin↔closed↔peek range in
// one gesture and the curtain follows the finger off-screen freely);
// the committing curtain DISPLACEMENT is still
// `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` ≈ 61 px — essentially «drag
// the curtain across the full tabs-row height». The anti-accidental
// gate is provided by the dedicated handle hit-zone (intentional
// surface) — the chat list under the curtain is left to native
// scroll and never engages a pin path, so there's no scroll-vs-pin
// ambiguity to disambiguate.
// the curtain across the full tabs-row height». On the body the same
// displacement is reached with a longer finger pull because the body
// path is rubber-banded (×0.65).
//
// Unpin is the one exception that keeps a hard ±PIN_TRAVEL_PX clamp:
// the handle-only contract makes it a deliberate full-travel pull,
// so we don't want the finger overshooting past closed into peek
// territory mid-gesture.
export const PIN_COMMIT_THRESHOLD = 0.95;
// Drag-handle hit-zone at the top of the curtain. The handle is the
// AUTHORITATIVE gesture surface — pin, unpin, peek, close-peek and
// form-close all bind here with 1:1 finger ↔ curtain tracking, no
// matter whether the chat list inside the curtain is scrollable. See
// `useCurtainHandleGesture` for the full state machine.
// Drag-handle hit-zone at the top of the curtain. NATIVE-ONLY: the
// handle is rendered only when `isNativePlatform()` is true (see
// StreamHeader.tsx) — on web (desktop / Electron) the curtain has
// no interactive snap states, so the handle would be pure
// decoration and is omitted entirely.
//
// On native the handle is the AUTHORITATIVE gesture surface —
// closed-free / unpin / close-peek / form-close all bind here with
// 1:1 finger ↔ curtain tracking, no matter whether the chat list
// inside the curtain is scrollable. See `useCurtainHandleGesture`
// for the full state machine.
//
// A parallel `useCurtainBodyGesture` bound to the curtain's body
// (everything below the handle) handles drag from anywhere on the
// card, but only when the inner chat list has no scrollable content
// — its dynamics are rubber-banded so the body drag reads as
// physically «heavier» than the handle's crisp pull.
// handles drag from anywhere on the card, but only when the inner
// chat list has no scrollable content AND the curtain isn't pinned
// (unpin is handle-only). Its dynamics are rubber-banded so the
// body drag reads as physically «heavier» than the handle's crisp
// pull.
//
// Size: 32 px tall — enough touch target to land on comfortably with
// a thumb (the visible grabber pill inside is much smaller, see
// `StreamHeader.css.ts::handleBar`). The list (or DirectEmpty / the
// equivalent placeholder) starts 32 px below the curtain's top edge.
// equivalent placeholder) starts 32 px below the curtain's top edge
// on native; on web the list sits flush at the curtain's top.
export const HANDLE_HEIGHT_PX = 32;

View file

@ -25,6 +25,15 @@ type Args = {
// when the touch starts inside the handle's hit-zone (the handle
// hook has already armed for that touch).
handleRef: MutableRefObject<HTMLDivElement | null>;
// The `bottomPinned` slot at the bottom of the curtain (hosts
// DirectSelfRow, ChannelCreateRow, WorkspaceFooter). These rows
// open their own bottom sheets via vertical drag, so a touch that
// starts there must NOT engage the curtain body — otherwise the
// user's «pull settings up» gesture would also pin the curtain
// and the two motions would visually fight. `null` is fine (the
// surface has no bottomPinned content); the contains() check is
// optional-chained.
bottomPinnedRef: MutableRefObject<HTMLDivElement | null>;
// Scroll viewport of the chat list inside the curtain. The body
// gesture engages only when this element is NOT scrollable
// (scrollHeight ≤ clientHeight + 1): on long lists the user's
@ -86,9 +95,17 @@ type Args = {
// Skip the scrollable-bail in that case — the body's visible area is
// the strip BELOW the form, and a drag there is unambiguously a
// form-close intent (the only valid transition from form-* snap).
//
// Pinned override: the body gesture is INERT while the curtain is
// pinned. Unpin is exclusively the handle's contract — the user has
// to grab the dedicated pin-handle to release the lock, so an
// accidental drag anywhere on the visible card doesn't undo it. We
// bail at touchstart so no listener side-effects (preventDefault,
// liveDrag emit, …) can fire either.
export function useCurtainBodyGesture({
curtainRef,
handleRef,
bottomPinnedRef,
scrollRef,
snap,
pinned,
@ -134,11 +151,20 @@ export function useCurtainBodyGesture({
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
// Pinned bail — handle owns unpin exclusively. See the «Pinned
// override» note above the hook for the rationale.
if (pinnedRef.current) return;
// Hand off to the handle hook if the touch starts inside the
// handle's 32 px hit-zone — the handle's own listener has
// already armed for this touch.
const target = e.target as Node | null;
if (target && handleRef.current?.contains(target)) return;
// Hand off to the bottomPinned region (DirectSelfRow,
// WorkspaceFooter, ChannelCreateRow). Those rows host their
// own drag-to-open bottom sheets — engaging the curtain
// gesture here would pin the curtain in parallel with the
// sheet opening, and the two motions would visually fight.
if (target && bottomPinnedRef.current?.contains(target)) return;
// Scroll-aware bail: leave a scrollable chat list to its native
// vertical scroll. Skipped in form-* snaps because the visible
// body area there is the strip BELOW the form (where the list
@ -206,24 +232,26 @@ export function useCurtainBodyGesture({
// only the finger pull needed differs.
let atCommit = false;
switch (transition) {
case 'pin':
// Rubber-banded up, clamped at the safe-top edge.
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta * RUBBER_BAND));
atCommit = -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
break;
case 'unpin':
// Rubber-banded down, clamped at the closed-resting edge.
lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta * RUBBER_BAND));
atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
break;
case 'peek':
// Rubber-banded down. Bounds come from the direction guard
// above plus the snap clamp on touchend, so no extra clamp —
// matches the original list-bound peek feel.
case 'closed-free':
// Rubber-banded free-range drag spanning pin↔closed↔peek
// in one motion. NO clamps either side — the curtain
// follows the finger off-screen upward and continuously
// into peek territory downward. Direction-aware atCommit
// shows the right commit feedback for whichever side the
// user is leaning into. Mirrors the handle's `closed-free`
// but with 0.65× displacement so the body drag reads as
// physically «heavier».
lastDelta = delta * RUBBER_BAND;
atCommit = lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
atCommit =
lastDelta <= 0
? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD
: lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break;
case 'close-peek':
// Rubber-banded up. No clamp either side — matches the
// original list-bound peek feel; a downward jitter past the
// peek snap is visually negligible against the rubber-band
// damping.
lastDelta = delta * RUBBER_BAND;
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break;
@ -233,6 +261,11 @@ export function useCurtainBodyGesture({
lastDelta = Math.min(0, delta * RUBBER_BAND);
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
break;
// `pinned-free` is intentionally absent — the pinned-bail
// at touchstart prevents the body hook from ever resolving
// to it. If a future change exposes pinned-free on the
// body, add the dispatch alongside this default so the
// linter keeps the switch exhaustive.
default:
break;
}
@ -249,22 +282,13 @@ export function useCurtainBodyGesture({
return;
}
switch (transition) {
case 'pin':
case 'closed-free':
// Direction-aware commit, sign-exclusive: pin wins UP-side,
// peek wins DOWN-side, below both thresholds spring back to
// closed.
if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(true);
} else {
setLiveDrag(0, false);
}
break;
case 'unpin':
if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(false);
} else {
setLiveDrag(0, false);
}
break;
case 'peek':
if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
} else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
commitRef.current('peek');
} else {
setLiveDrag(0, false);
@ -322,5 +346,5 @@ export function useCurtainBodyGesture({
// `setPinned` and `commit` are ref-mirrored. Only `disabled` needs
// to tear listeners down — it's the sole effect dep.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [curtainRef, handleRef, scrollRef, setLiveDrag, disabled]);
}, [curtainRef, handleRef, bottomPinnedRef, scrollRef, setLiveDrag, disabled]);
}

View file

@ -54,7 +54,30 @@ type Args = {
// on the curtain body) decide how raw finger displacement translates
// into curtain motion — see `onTouchMove` here for the 1:1 branches
// and `useCurtainBodyGesture` for the rubber-banded equivalents.
export type CurtainTransition = 'pin' | 'unpin' | 'peek' | 'close-peek' | 'form-close';
//
// `closed-free` is the single free-range transition that spans the
// full pin↔closed↔peek vertical range in one gesture. From the closed
// snap, neither direction is locked at the dead-zone: the user can
// drag up past the safe-top zone OR down through the chip area in
// one motion, and the release decides pin / peek / snap-back based
// on the final position. The earlier pair of one-shot `pin` and
// `peek` transitions used a hard «gate» at the start point (each
// direction was clamped to one side of 0 once the dead-zone resolved
// the direction) and the user reported this as a regression — drag
// up, then back down, ran into an invisible wall at the closed
// position before peek could engage.
//
// `pinned-free` is the symmetric free-range transition for the
// pinned overlay: from pinned + drag DOWN the curtain follows the
// finger all the way through closed into peek territory in one
// motion. On release, peek wins if the finger crossed the absolute
// peek planka (PIN_TRAVEL_PX + COMMIT_THRESHOLD × PEEK_TRAVEL_PX —
// the same visual point peek commits at from closed-free), unpin
// wins if at least the unpin threshold was reached, otherwise snap
// back to pinned. UP is no-op (no destination above pinned). Only
// the handle resolves to `pinned-free` — the body gesture bails at
// touchstart while pinned so unpin remains a deliberate handle pull.
export type CurtainTransition = 'closed-free' | 'pinned-free' | 'close-peek' | 'form-close';
// Decide which transition the gesture arms based on the snap state
// at direction-resolution time and the finger direction. `null` means
@ -63,10 +86,17 @@ export type CurtainTransition = 'pin' | 'unpin' | 'peek' | 'close-peek' | 'form-
// owns the touch.
//
// Direction guards encoded here:
// * pinned + UP → no-op (would push the curtain past safe-top).
// * pinned + DOWN → unpin.
// * closed + UP → pin.
// * closed + DOWN → peek.
// * pinned + UP → no-op (would push the curtain past safe-top
// on commit — no destination above pinned).
// * pinned + DOWN → pinned-free (HANDLE-only contract — the body
// hook bails entirely while pinned so unpin /
// peek-from-pinned stays a deliberate handle
// pull. See
// `useCurtainBodyGesture::onTouchStart`).
// * closed (any) → closed-free (single transition spanning the
// whole pin↔closed↔peek range; direction at
// the dead-zone matters only for the
// horizontal-bail check).
// * peek + UP → close-peek (retreat to closed).
// * peek + DOWN → no-op (nothing lower to reveal).
// * form-* + UP → form-close.
@ -76,8 +106,8 @@ export function resolveCurtainTransition(
pinned: boolean,
direction: 'up' | 'down'
): CurtainTransition | null {
if (pinned) return direction === 'down' ? 'unpin' : null;
if (snap === 'closed') return direction === 'up' ? 'pin' : 'peek';
if (pinned) return direction === 'down' ? 'pinned-free' : null;
if (snap === 'closed') return 'closed-free';
if (snap === 'peek') return direction === 'up' ? 'close-peek' : null;
if (isFormSnap(snap)) return direction === 'up' ? 'form-close' : null;
return null;
@ -88,30 +118,52 @@ export function resolveCurtainTransition(
// desktop.
//
// The handle is the «authoritative» gesture surface — it owns every
// transition (pin, unpin, peek, close-peek, form-close) with crisp
// 1:1 finger ↔ curtain tracking regardless of whether the chat list
// inside the curtain is scrollable. The curtain BODY has a parallel
// gesture (`useCurtainBodyGesture`) with rubber-banded dynamics that
// only engages when the body's chat list has no scrollable content —
// so the user can pull the curtain «from anywhere» on empty / short
// lists but a real list-scroll is never hijacked under their finger.
// transition (closed-free, pinned-free, close-peek, form-close)
// with crisp 1:1 finger ↔ curtain tracking regardless of whether
// the chat list inside the curtain is scrollable. The curtain BODY
// has a parallel gesture (`useCurtainBodyGesture`) with rubber-
// banded dynamics that only engages when the body's chat list has
// no scrollable content — so the user can pull the curtain «from
// anywhere» on empty / short lists but a real list-scroll is never
// hijacked under their finger. The body is also fully inert while
// pinned, so unpin (and unpin → peek overshoot) stays a deliberate
// handle pull.
// History note: an earlier `useCurtainGesture` bound the peek /
// form-close paths to the list scroll viewport directly. That coupling
// produced repeating «drag-up at scrollTop=0 hijacks for pin» / «drag-
// down at scrollTop=0 hijacks for peek» bugs and was removed when
// pin / unpin moved here.
//
// All five transitions track the finger 1:1, clamped at the relevant
// snap edge so jitter past the destination doesn't visually overshoot:
// * pin / unpin — clamp ±PIN_TRAVEL_PX, commit at
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX
// («дотянул прям до самого верха»).
// * peek / close-peek — clamp ±PEEK_TRAVEL_PX, commit at
// Per-transition dynamics — all track the finger 1:1, but the clamp
// shapes differ to keep on-screen motion sensible while preserving
// the «drag up off-screen from anywhere» feel the user explicitly
// asked for:
// * closed-free — NO clamps either side. Finger goes off-
// screen up → curtain follows past safe-top;
// finger crosses back below the start point →
// curtain continues into peek territory in
// the same gesture. Direction-aware commit
// on release: pin if pulled UP past
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, peek
// if pulled DOWN past COMMIT_THRESHOLD ×
// PEEK_TRAVEL_PX, else snap back to closed.
// * pinned-free — DOWN-only free-range drag from pinned.
// Clamped at 0 below (no destination above
// pinned), NO upper clamp — the finger can
// carry the curtain through closed into
// peek territory in one motion. Release
// decides peek (lastDelta ≥ PIN_TRAVEL_PX +
// COMMIT_THRESHOLD × PEEK_TRAVEL_PX), unpin
// (lastDelta ≥ PIN_COMMIT_THRESHOLD ×
// PIN_TRAVEL_PX), or snap back to pinned.
// * close-peek — capped at 0 below (no transition lower
// than peek), NO upper clamp (drag past
// closed into safe-top freely). Commit at
// COMMIT_THRESHOLD × PEEK_TRAVEL_PX.
// * form-close — capped at 0 so a downward jitter can't push
// the curtain below its form-snap position.
// Commit at ACTIVE_CLOSE_THRESHOLD_PX
// (absolute distance, not a fraction).
// * form-close — capped at 0 so a downward jitter can't
// push the curtain below its form-snap top,
// NO upper clamp. Commit at
// ACTIVE_CLOSE_THRESHOLD_PX (absolute).
//
// Handle visual: emitHandle(true, atCommit) fires on every transition
// during touchmove so the grabber pill animates Primary-blue +
@ -223,36 +275,47 @@ export function useCurtainHandleGesture({
engaged = true;
e.preventDefault();
// Clamp / rubber-band the raw finger delta into the live curtain
// displacement (`lastDelta`). Stored separately because the
// commit math on release needs the same value the curtain was
// visually showing.
// Clamp the raw finger delta into the live curtain displacement
// (`lastDelta`). Stored separately because the commit math on
// release needs the same value the curtain was visually showing.
let atCommit = false;
switch (transition) {
case 'pin':
// 1:1 up, clamped so the curtain doesn't enter the
// system-tray safe-top zone.
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta));
atCommit = -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
case 'closed-free':
// Single free-range drag spanning pin↔closed↔peek. 1:1 with
// NO clamps either side: the curtain follows the finger off-
// screen upward (past safe-top) and continuously into peek
// territory downward in the same gesture. The release decides
// pin / peek / snap-back from the final lastDelta.
lastDelta = delta;
// Direction-aware atCommit so the grabber pill stretches
// whichever way the user is committing. Pin and peek are
// sign-exclusive (one branch can't fire simultaneously with
// the other) so a simple ternary on `lastDelta` suffices.
atCommit =
lastDelta <= 0
? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD
: lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break;
case 'unpin':
// 1:1 down, clamped so the curtain doesn't descend past its
// `closed` resting top during the drag.
lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta));
case 'pinned-free':
// 1:1 down from pinned. Clamped at 0 below (a downward
// jitter past the start mustn't push the curtain into
// safe-top — there's no destination above pinned), NO
// upper clamp — the curtain follows the finger through
// closed into peek territory in one motion.
lastDelta = Math.max(0, delta);
// atCommit fires as soon as ANY commit qualifies (the
// grabber pill stretches to signal «release works here»);
// it stays true past the unpin threshold all the way
// through peek, since both are valid landing zones.
atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
break;
case 'peek':
// 1:1 down, clamped at +PEEK_TRAVEL_PX so a long pull past
// the peek snap doesn't visually overshoot. Math.max(0,…)
// guards against a momentary direction reversal nudging the
// curtain above the closed origin while transition is still
// armed for «down».
lastDelta = Math.max(0, Math.min(PEEK_TRAVEL_PX, delta));
atCommit = lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break;
case 'close-peek':
// 1:1 up; delta is negative. Symmetric clamp to peek above.
lastDelta = Math.min(0, Math.max(-PEEK_TRAVEL_PX, delta));
// 1:1 up; delta is negative. Lower-capped at 0 (a downward
// jitter shouldn't push past the peek snap), NO upper clamp
// — the curtain follows the finger off-screen freely in the
// safe-top direction, matching the «drag up off-screen from
// anywhere» expectation.
lastDelta = Math.min(0, delta);
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break;
case 'form-close':
@ -285,27 +348,42 @@ export function useCurtainHandleGesture({
// transition re-enabled. Non-commit paths drop the live drag back
// to 0 with transition active so the curtain springs back.
switch (transition) {
case 'pin':
case 'closed-free':
// Direction-aware commit from the free-range drag. Pin
// wins over peek if both somehow qualified (sign-exclusive
// in practice — lastDelta can't be simultaneously <0 and
// >0). Below either threshold, spring back to closed.
if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(true);
} else {
setLiveDrag(0, false);
}
break;
case 'unpin':
if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(false);
} else {
setLiveDrag(0, false);
}
break;
case 'peek':
if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
} else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
commitRef.current('peek');
} else {
setLiveDrag(0, false);
}
break;
case 'pinned-free':
// Two-tier commit: peek wins if the finger crossed the
// absolute peek planka (matches the visual point peek
// commits at from closed-free — PIN_TRAVEL_PX to get to
// closed + COMMIT_THRESHOLD × PEEK_TRAVEL_PX through the
// chip area); otherwise unpin if at least the unpin
// threshold was reached; else snap back to pinned.
//
// The peek branch MUST clear `pinned` before committing
// the snap. The curtain's resting top is
// `pinned ? 0 : snapTopPx(snap)` — so commit('peek')
// alone would set snap='peek' yet leave the curtain
// visually at top=0 (the pin overlay wins). Both updates
// batch into one render inside this touchend handler.
if (lastDelta >= PIN_TRAVEL_PX + COMMIT_THRESHOLD * PEEK_TRAVEL_PX) {
setPinnedRef.current(false);
commitRef.current('peek');
} else if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(false);
} else {
setLiveDrag(0, false);
}
break;
case 'close-peek':
if (-lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
commitRef.current('closed');