From 8fb885df1b011f124216545cf160129f1bd9db0a Mon Sep 17 00:00:00 2001 From: heaven Date: Wed, 20 May 2026 00:26:10 +0300 Subject: [PATCH] =?UTF-8?q?feat(stream-header):=20free-range=20curtain=20d?= =?UTF-8?q?rag=20through=20full=20pin=E2=86=94closed=E2=86=94peek=20range?= =?UTF-8?q?=20with=20bottomPinned-aware=20body=20bail=20and=20native-only?= =?UTF-8?q?=20handle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/stream-header/StreamHeader.tsx | 66 +++--- src/app/components/stream-header/geometry.ts | 47 ++-- .../stream-header/useCurtainBodyGesture.ts | 84 +++++--- .../stream-header/useCurtainHandleGesture.ts | 202 ++++++++++++------ 4 files changed, 263 insertions(+), 136 deletions(-) diff --git a/src/app/components/stream-header/StreamHeader.tsx b/src/app/components/stream-header/StreamHeader.tsx index b1956bf3..0da5db6e 100644 --- a/src/app/components/stream-header/StreamHeader.tsx +++ b/src/app/components/stream-header/StreamHeader.tsx @@ -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(null); const curtainRef = useRef(null); + const bottomPinnedRef = useRef(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. */} -
-
-
+ {isNativePlatform() && ( +
+
+
+ )} {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 && (
diff --git a/src/app/components/stream-header/geometry.ts b/src/app/components/stream-header/geometry.ts index 72a3c153..d7bcc48f 100644 --- a/src/app/components/stream-header/geometry.ts +++ b/src/app/components/stream-header/geometry.ts @@ -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; diff --git a/src/app/components/stream-header/useCurtainBodyGesture.ts b/src/app/components/stream-header/useCurtainBodyGesture.ts index 7abe0144..94a44dfa 100644 --- a/src/app/components/stream-header/useCurtainBodyGesture.ts +++ b/src/app/components/stream-header/useCurtainBodyGesture.ts @@ -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; + // 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; // 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]); } diff --git a/src/app/components/stream-header/useCurtainHandleGesture.ts b/src/app/components/stream-header/useCurtainHandleGesture.ts index e5d38732..4f0bfa5d 100644 --- a/src/app/components/stream-header/useCurtainHandleGesture.ts +++ b/src/app/components/stream-header/useCurtainHandleGesture.ts @@ -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');