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:
heaven 2026-05-20 00:26:10 +03:00
parent ab283e9788
commit 8fb885df1b
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: // Two parallel curtain-gesture surfaces:
// //
// * `useCurtainHandleGesture` — the dedicated 32 px drag-handle // * `useCurtainHandleGesture` — the dedicated 32 px drag-handle
// at the top of the curtain. Crisp 1:1 finger ↔ curtain on // at the top of the curtain. Crisp 1:1 finger ↔ curtain. From
// every transition (pin, unpin, peek, close-peek, form-close). // 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 — // Engages regardless of whether the chat list is scrollable —
// the handle is a distinct surface and never competes with list // the handle is a distinct surface and never competes with list
// scroll. // scroll. Only rendered on native (`isNativePlatform()`).
// //
// * `useCurtainBodyGesture` — anywhere on the curtain body // * `useCurtainBodyGesture` — anywhere on the curtain body
// OUTSIDE the handle (chat list, empty-state placeholder, // OUTSIDE the handle (chat list, empty-state placeholder).
// bottom-pinned row). Rubber-banded (0.65) on every transition // Rubber-banded (0.65) for all transitions, so the body drag
// so the body drag reads as physically «heavier» than the // reads as physically «heavier» than the handle's crisp pull.
// handle's crisp pull. Engages ONLY when the chat list has no // Engages only when the chat list has no scrollable content;
// scrollable content — long lists keep native vertical scroll; // additionally bails on touches that start inside the bottom-
// short / empty lists let the user pull the curtain «from // pinned slot (DirectSelfRow / WorkspaceFooter / ChannelCreate
// anywhere». // 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 // Both hooks share `handleVisual` (mirrors desktop
// `PageNavResizeHandle`: `dragging` lights up the grabber pill; // `PageNavResizeHandle`: `dragging` lights up the grabber pill;
@ -154,6 +158,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
// visual. // visual.
const handleRef = useRef<HTMLDivElement>(null); const handleRef = useRef<HTMLDivElement>(null);
const curtainRef = useRef<HTMLDivElement>(null); const curtainRef = useRef<HTMLDivElement>(null);
const bottomPinnedRef = useRef<HTMLDivElement>(null);
const [handleVisual, setHandleVisual] = useState<{ dragging: boolean; atCommit: boolean }>({ const [handleVisual, setHandleVisual] = useState<{ dragging: boolean; atCommit: boolean }>({
dragging: false, dragging: false,
atCommit: false, atCommit: false,
@ -171,6 +176,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
useCurtainBodyGesture({ useCurtainBodyGesture({
curtainRef, curtainRef,
handleRef, handleRef,
bottomPinnedRef,
scrollRef, scrollRef,
snap: curtain.snap, snap: curtain.snap,
pinned: curtain.pinned, pinned: curtain.pinned,
@ -448,14 +454,17 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
}} }}
onTransitionEnd={onCurtainTransitionEnd} onTransitionEnd={onCurtainTransitionEnd}
> >
{/* Drag handle (native-only behaviour, but rendered on all {/* Drag handle native-only. On web (desktop browsers,
platforms so the layout stays identical the gesture hook Electron) the curtain has no interactive snap states, so
short-circuits off-native). Hosts the entire curtain the handle would be pure decoration with no behaviour
gesture surface pin, unpin, peek, close-peek and behind it; rendering it conditionally drops the 32 px
form-close all bind here, leaving the chat list to native grabber strip on those surfaces and lets the chat list
scroll. Stays mounted across snap transitions so the sit flush against the curtain's rounded top.
gesture surface is always reachable when there is one to
make. 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 `data-dragging` / `data-at-commit` mirror the desktop
`PageNavResizeHandle`: CSS selectors on `handleBar` light `PageNavResizeHandle`: CSS selectors on `handleBar` light
@ -463,6 +472,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
Both attrs are emitted/cleared only via React state set by Both attrs are emitted/cleared only via React state set by
the gesture hook (dedup'd), so the handle visual updates the gesture hook (dedup'd), so the handle visual updates
without slamming the DOM on every touchmove. */} without slamming the DOM on every touchmove. */}
{isNativePlatform() && (
<div <div
ref={handleRef} ref={handleRef}
className={css.handle} className={css.handle}
@ -472,6 +482,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
> >
<div className={css.handleBar} /> <div className={css.handleBar} />
</div> </div>
)}
{children} {children}
{/* `bottomPinned` (DirectSelfRow, ChannelCreateRow, etc.) is {/* `bottomPinned` (DirectSelfRow, ChannelCreateRow, etc.) is
kept mounted across snaps so the curtain reads as a self- 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). */} `keyboardOpen` effect above for the rationale). */}
{bottomPinned && ( {bottomPinned && (
<div <div
ref={bottomPinnedRef}
className={css.bottomPinnedSlot} className={css.bottomPinnedSlot}
style={keyboardOpen ? { height: 0, overflow: 'hidden' } : undefined} 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 // release for the snap to flip. Anything shorter reads as accidental
// and springs back to the previous resting snap. // and springs back to the previous resting snap.
// //
// With 1:1 finger ↔ curtain tracking (no rubber-band on pin / unpin — // On the handle the up direction is 1:1 with no upper clamp (the
// see `useCurtainHandleGesture`), the committing finger pull is // «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 // `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` ≈ 61 px — essentially «drag
// the curtain across the full tabs-row height». The anti-accidental // the curtain across the full tabs-row height». On the body the same
// gate is provided by the dedicated handle hit-zone (intentional // displacement is reached with a longer finger pull because the body
// surface) — the chat list under the curtain is left to native // path is rubber-banded (×0.65).
// scroll and never engages a pin path, so there's no scroll-vs-pin //
// ambiguity to disambiguate. // 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; export const PIN_COMMIT_THRESHOLD = 0.95;
// Drag-handle hit-zone at the top of the curtain. The handle is the // Drag-handle hit-zone at the top of the curtain. NATIVE-ONLY: the
// AUTHORITATIVE gesture surface — pin, unpin, peek, close-peek and // handle is rendered only when `isNativePlatform()` is true (see
// form-close all bind here with 1:1 finger ↔ curtain tracking, no // StreamHeader.tsx) — on web (desktop / Electron) the curtain has
// matter whether the chat list inside the curtain is scrollable. See // no interactive snap states, so the handle would be pure
// `useCurtainHandleGesture` for the full state machine. // 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 // A parallel `useCurtainBodyGesture` bound to the curtain's body
// (everything below the handle) handles drag from anywhere on the // handles drag from anywhere on the card, but only when the inner
// card, but only when the inner chat list has no scrollable content // chat list has no scrollable content AND the curtain isn't pinned
// — its dynamics are rubber-banded so the body drag reads as // (unpin is handle-only). Its dynamics are rubber-banded so the
// physically «heavier» than the handle's crisp pull. // body drag reads as physically «heavier» than the handle's crisp
// pull.
// //
// Size: 32 px tall — enough touch target to land on comfortably with // Size: 32 px tall — enough touch target to land on comfortably with
// a thumb (the visible grabber pill inside is much smaller, see // a thumb (the visible grabber pill inside is much smaller, see
// `StreamHeader.css.ts::handleBar`). The list (or DirectEmpty / the // `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; 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 // when the touch starts inside the handle's hit-zone (the handle
// hook has already armed for that touch). // hook has already armed for that touch).
handleRef: MutableRefObject<HTMLDivElement | null>; 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 // Scroll viewport of the chat list inside the curtain. The body
// gesture engages only when this element is NOT scrollable // gesture engages only when this element is NOT scrollable
// (scrollHeight ≤ clientHeight + 1): on long lists the user's // (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 // 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 // the strip BELOW the form, and a drag there is unambiguously a
// form-close intent (the only valid transition from form-* snap). // 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({ export function useCurtainBodyGesture({
curtainRef, curtainRef,
handleRef, handleRef,
bottomPinnedRef,
scrollRef, scrollRef,
snap, snap,
pinned, pinned,
@ -134,11 +151,20 @@ export function useCurtainBodyGesture({
const onTouchStart = (e: TouchEvent) => { const onTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return; 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 // Hand off to the handle hook if the touch starts inside the
// handle's 32 px hit-zone — the handle's own listener has // handle's 32 px hit-zone — the handle's own listener has
// already armed for this touch. // already armed for this touch.
const target = e.target as Node | null; const target = e.target as Node | null;
if (target && handleRef.current?.contains(target)) return; 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 // Scroll-aware bail: leave a scrollable chat list to its native
// vertical scroll. Skipped in form-* snaps because the visible // vertical scroll. Skipped in form-* snaps because the visible
// body area there is the strip BELOW the form (where the list // 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. // only the finger pull needed differs.
let atCommit = false; let atCommit = false;
switch (transition) { switch (transition) {
case 'pin': case 'closed-free':
// Rubber-banded up, clamped at the safe-top edge. // Rubber-banded free-range drag spanning pin↔closed↔peek
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta * RUBBER_BAND)); // in one motion. NO clamps either side — the curtain
atCommit = -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD; // follows the finger off-screen upward and continuously
break; // into peek territory downward. Direction-aware atCommit
case 'unpin': // shows the right commit feedback for whichever side the
// Rubber-banded down, clamped at the closed-resting edge. // user is leaning into. Mirrors the handle's `closed-free`
lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta * RUBBER_BAND)); // but with 0.65× displacement so the body drag reads as
atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD; // physically «heavier».
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.
lastDelta = delta * RUBBER_BAND; 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; break;
case 'close-peek': 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; lastDelta = delta * RUBBER_BAND;
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD; atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break; break;
@ -233,6 +261,11 @@ export function useCurtainBodyGesture({
lastDelta = Math.min(0, delta * RUBBER_BAND); lastDelta = Math.min(0, delta * RUBBER_BAND);
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX; atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
break; 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: default:
break; break;
} }
@ -249,22 +282,13 @@ export function useCurtainBodyGesture({
return; return;
} }
switch (transition) { 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) { if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(true); setPinnedRef.current(true);
} else { } else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
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) {
commitRef.current('peek'); commitRef.current('peek');
} else { } else {
setLiveDrag(0, false); setLiveDrag(0, false);
@ -322,5 +346,5 @@ export function useCurtainBodyGesture({
// `setPinned` and `commit` are ref-mirrored. Only `disabled` needs // `setPinned` and `commit` are ref-mirrored. Only `disabled` needs
// to tear listeners down — it's the sole effect dep. // to tear listeners down — it's the sole effect dep.
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 // on the curtain body) decide how raw finger displacement translates
// into curtain motion — see `onTouchMove` here for the 1:1 branches // into curtain motion — see `onTouchMove` here for the 1:1 branches
// and `useCurtainBodyGesture` for the rubber-banded equivalents. // 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 // Decide which transition the gesture arms based on the snap state
// at direction-resolution time and the finger direction. `null` means // 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. // owns the touch.
// //
// Direction guards encoded here: // Direction guards encoded here:
// * pinned + UP → no-op (would push the curtain past safe-top). // * pinned + UP → no-op (would push the curtain past safe-top
// * pinned + DOWN → unpin. // on commit — no destination above pinned).
// * closed + UP → pin. // * pinned + DOWN → pinned-free (HANDLE-only contract — the body
// * closed + DOWN → peek. // 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 + UP → close-peek (retreat to closed).
// * peek + DOWN → no-op (nothing lower to reveal). // * peek + DOWN → no-op (nothing lower to reveal).
// * form-* + UP → form-close. // * form-* + UP → form-close.
@ -76,8 +106,8 @@ export function resolveCurtainTransition(
pinned: boolean, pinned: boolean,
direction: 'up' | 'down' direction: 'up' | 'down'
): CurtainTransition | null { ): CurtainTransition | null {
if (pinned) return direction === 'down' ? 'unpin' : null; if (pinned) return direction === 'down' ? 'pinned-free' : null;
if (snap === 'closed') return direction === 'up' ? 'pin' : 'peek'; if (snap === 'closed') return 'closed-free';
if (snap === 'peek') return direction === 'up' ? 'close-peek' : null; if (snap === 'peek') return direction === 'up' ? 'close-peek' : null;
if (isFormSnap(snap)) return direction === 'up' ? 'form-close' : null; if (isFormSnap(snap)) return direction === 'up' ? 'form-close' : null;
return null; return null;
@ -88,30 +118,52 @@ export function resolveCurtainTransition(
// desktop. // desktop.
// //
// The handle is the «authoritative» gesture surface — it owns every // The handle is the «authoritative» gesture surface — it owns every
// transition (pin, unpin, peek, close-peek, form-close) with crisp // transition (closed-free, pinned-free, close-peek, form-close)
// 1:1 finger ↔ curtain tracking regardless of whether the chat list // with crisp 1:1 finger ↔ curtain tracking regardless of whether
// inside the curtain is scrollable. The curtain BODY has a parallel // the chat list inside the curtain is scrollable. The curtain BODY
// gesture (`useCurtainBodyGesture`) with rubber-banded dynamics that // has a parallel gesture (`useCurtainBodyGesture`) with rubber-
// only engages when the body's chat list has no scrollable content — // banded dynamics that only engages when the body's chat list has
// so the user can pull the curtain «from anywhere» on empty / short // no scrollable content — so the user can pull the curtain «from
// lists but a real list-scroll is never hijacked under their finger. // 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 / // History note: an earlier `useCurtainGesture` bound the peek /
// form-close paths to the list scroll viewport directly. That coupling // form-close paths to the list scroll viewport directly. That coupling
// produced repeating «drag-up at scrollTop=0 hijacks for pin» / «drag- // produced repeating «drag-up at scrollTop=0 hijacks for pin» / «drag-
// down at scrollTop=0 hijacks for peek» bugs and was removed when // down at scrollTop=0 hijacks for peek» bugs and was removed when
// pin / unpin moved here. // pin / unpin moved here.
// //
// All five transitions track the finger 1:1, clamped at the relevant // Per-transition dynamics — all track the finger 1:1, but the clamp
// snap edge so jitter past the destination doesn't visually overshoot: // shapes differ to keep on-screen motion sensible while preserving
// * pin / unpin — clamp ±PIN_TRAVEL_PX, commit at // the «drag up off-screen from anywhere» feel the user explicitly
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX // asked for:
// («дотянул прям до самого верха»). // * closed-free — NO clamps either side. Finger goes off-
// * peek / close-peek — clamp ±PEEK_TRAVEL_PX, commit at // 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. // COMMIT_THRESHOLD × PEEK_TRAVEL_PX.
// * form-close — capped at 0 so a downward jitter can't push // * form-close — capped at 0 so a downward jitter can't
// the curtain below its form-snap position. // push the curtain below its form-snap top,
// Commit at ACTIVE_CLOSE_THRESHOLD_PX // NO upper clamp. Commit at
// (absolute distance, not a fraction). // ACTIVE_CLOSE_THRESHOLD_PX (absolute).
// //
// Handle visual: emitHandle(true, atCommit) fires on every transition // Handle visual: emitHandle(true, atCommit) fires on every transition
// during touchmove so the grabber pill animates Primary-blue + // during touchmove so the grabber pill animates Primary-blue +
@ -223,36 +275,47 @@ export function useCurtainHandleGesture({
engaged = true; engaged = true;
e.preventDefault(); e.preventDefault();
// Clamp / rubber-band the raw finger delta into the live curtain // Clamp the raw finger delta into the live curtain displacement
// displacement (`lastDelta`). Stored separately because the // (`lastDelta`). Stored separately because the commit math on
// commit math on release needs the same value the curtain was // release needs the same value the curtain was visually showing.
// visually showing.
let atCommit = false; let atCommit = false;
switch (transition) { switch (transition) {
case 'pin': case 'closed-free':
// 1:1 up, clamped so the curtain doesn't enter the // Single free-range drag spanning pin↔closed↔peek. 1:1 with
// system-tray safe-top zone. // NO clamps either side: the curtain follows the finger off-
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta)); // screen upward (past safe-top) and continuously into peek
atCommit = -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD; // 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; break;
case 'unpin': case 'pinned-free':
// 1:1 down, clamped so the curtain doesn't descend past its // 1:1 down from pinned. Clamped at 0 below (a downward
// `closed` resting top during the drag. // jitter past the start mustn't push the curtain into
lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta)); // 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; atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
break; 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': case 'close-peek':
// 1:1 up; delta is negative. Symmetric clamp to peek above. // 1:1 up; delta is negative. Lower-capped at 0 (a downward
lastDelta = Math.min(0, Math.max(-PEEK_TRAVEL_PX, delta)); // 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; atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break; break;
case 'form-close': case 'form-close':
@ -285,27 +348,42 @@ export function useCurtainHandleGesture({
// transition re-enabled. Non-commit paths drop the live drag back // transition re-enabled. Non-commit paths drop the live drag back
// to 0 with transition active so the curtain springs back. // to 0 with transition active so the curtain springs back.
switch (transition) { 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) { if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(true); setPinnedRef.current(true);
} else { } else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
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) {
commitRef.current('peek'); commitRef.current('peek');
} else { } else {
setLiveDrag(0, false); setLiveDrag(0, false);
} }
break; 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': case 'close-peek':
if (-lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) { if (-lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
commitRef.current('closed'); commitRef.current('closed');