From 67ee378b39709f293a1c53067884025434b37ccd Mon Sep 17 00:00:00 2001 From: heaven Date: Thu, 28 May 2026 00:28:38 +0300 Subject: [PATCH] fix(stream-header): block body's drag-up from closed so pinning the directs/channels/bots header now requires the dedicated pin handle --- .../components/stream-header/StreamHeader.tsx | 1 - .../stream-header/useCurtainBodyGesture.ts | 135 ++++++++++++------ 2 files changed, 88 insertions(+), 48 deletions(-) diff --git a/src/app/components/stream-header/StreamHeader.tsx b/src/app/components/stream-header/StreamHeader.tsx index 05a766c1..fda2608d 100644 --- a/src/app/components/stream-header/StreamHeader.tsx +++ b/src/app/components/stream-header/StreamHeader.tsx @@ -198,7 +198,6 @@ export function StreamHeader({ scrollRef, snap: curtain.snap, pinned: curtain.pinned, - setPinned: curtain.setPinned, setLiveDrag: curtain.setLiveDrag, commit: curtain.commit, disabled: gestureDisabled, diff --git a/src/app/components/stream-header/useCurtainBodyGesture.ts b/src/app/components/stream-header/useCurtainBodyGesture.ts index 0bb23cbc..cc00fef8 100644 --- a/src/app/components/stream-header/useCurtainBodyGesture.ts +++ b/src/app/components/stream-header/useCurtainBodyGesture.ts @@ -5,8 +5,6 @@ import { COMMIT_THRESHOLD, DIRECTION_DEAD_ZONE_PX, PEEK_TRAVEL_PX, - PIN_COMMIT_THRESHOLD, - PIN_TRAVEL_PX, RUBBER_BAND, } from './geometry'; import { CurtainSnap, isFormSnap } from './useCurtainState'; @@ -52,15 +50,17 @@ type Args = { // Current snap stop. Mirrored into a ref so the listener — bound // once per `disabled` flip — reads fresh values without rebinding. snap: CurtainSnap; - // Per-pane pinned overlay; also ref-mirrored. + // Per-pane pinned overlay; also ref-mirrored. Only READ here (used + // by the touchstart bail) — pin / unpin commits are the handle's + // exclusive contract, see «Direction asymmetry» on the hook. pinned: boolean; - setPinned: (next: boolean) => void; // Live drag delta sink — feeds the curtain's `top` via React state, // no direct DOM writes. setLiveDrag: (px: number, dragging: boolean) => void; - // Snap commit (peek / close-peek / form-close). Narrowed to the two - // non-form destinations the hook ever reaches. pin/unpin flips - // `pinned` instead. + // Snap commit. `'peek'` fires from closed-free's down-half; + // `'closed'` fires from close-peek and form-close. The pin / unpin + // paths are handle-only and never flip state through this setter + // from the body. commit: (next: 'peek' | 'closed') => void; // Suppress gesture binding entirely. Same conditions as the handle // hook — see StreamHeader's `gestureDisabled`. @@ -79,20 +79,44 @@ type Args = { // Why a second surface? On listing surfaces with content that fits in // one screen (empty Direct / Bots / Channels states, the ChannelsLanding // CTA, a workspace with few rooms) the user's natural «pull the curtain -// down to peek» / «push the curtain up to pin» gestures happen anywhere -// on the visible card. Restricting all motion to the 32 px handle on -// these surfaces felt artificial. On the other hand, surfaces with a -// scrollable list need their native vertical scroll preserved — so the -// body gesture is *conditional*: it engages only when the chat list -// has no scrollable content (scrollHeight ≤ clientHeight + 1). Long -// lists keep using the handle for curtain motion. +// down to peek» gesture happens anywhere on the visible card. +// Restricting all motion to the 32 px handle on these surfaces felt +// artificial. On the other hand, surfaces with a scrollable list need +// their native vertical scroll preserved — so the body gesture is +// *conditional*: it engages only when the chat list has no scrollable +// content (scrollHeight ≤ clientHeight + 1). Long lists keep using the +// handle for curtain motion. +// +// Direction asymmetry — pinning is handle-only, retracting is shared. +// The body engages on: +// * closed + DOWN → peek (closed-free, down-half only) +// * peek + UP → closed (close-peek) +// * form-* + UP → closed (form-close) +// The body does NOT engage on: +// * closed + UP → would be pin via closed-free's up-half. The +// user reported that arbitrary upward drag on +// the body made it too easy to accidentally +// close the directs/channels/bots header by +// pinning. Pin must be a deliberate gesture on +// the dedicated pin-handle. After close-peek / +// form-close lands at `closed`, the curtain +// can only go further up via the handle. +// * pinned + DOWN → unpin / peek-from-pinned. Same rationale: the +// pin handle owns the unpin contract too, so an +// accidental drag on the visible card can't +// undo it. Bailed at touchstart (see Pinned +// override below). +// The asymmetric block on closed+UP is implemented in onTouchMove +// after the transition resolves — we only bail closed-free's UP half, +// not every upward drag, so close-peek and form-close still engage on +// the body. // // Dynamics: all transitions use rubber-band 0.65 (= RUBBER_BAND) so // the body drag feels physically «heavier» than the handle's crisp // 1:1 — the user reads the two surfaces as distinct affordances. The // commit math is expressed in CURTAIN displacement (lastDelta), not -// raw finger pull, so a body «commit at PIN_COMMIT_THRESHOLD × -// PIN_TRAVEL_PX» visually matches a handle commit at the same point — +// raw finger pull, so a body «commit at COMMIT_THRESHOLD × +// PEEK_TRAVEL_PX» visually matches a handle commit at the same point — // only the finger pull needed to get there differs. // // Form-snap override: when a form is mounted, the chat list under it @@ -114,7 +138,6 @@ export function useCurtainBodyGesture({ scrollRef, snap, pinned, - setPinned, setLiveDrag, commit, disabled, @@ -124,8 +147,6 @@ export function useCurtainBodyGesture({ snapRef.current = snap; const pinnedRef = useRef(pinned); pinnedRef.current = pinned; - const setPinnedRef = useRef(setPinned); - setPinnedRef.current = setPinned; const commitRef = useRef(commit); commitRef.current = commit; const setHandleStateRef = useRef(setHandleState); @@ -218,14 +239,31 @@ export function useCurtainBodyGesture({ direction = delta > 0 ? 'down' : 'up'; transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction); if (transition === null) { - // (snap, pinned, direction) has no valid motion — pinned+up, - // peek+down, form+down. Bail without preventDefault so any - // native default (overscroll bounce, etc.) can still play. + // (snap, pinned, direction) has no valid motion — peek+down, + // form+down (pinned+up also resolves to null, though the + // touchstart pinned-bail already filters every pinned + // gesture before we reach here). Bail without preventDefault + // so any native default (overscroll bounce, etc.) can still + // play. startX = null; startY = null; direction = null; return; } + // Closed-free UP-half bail. closed-free is the only transition + // whose upward direction commits to pin — and pin via body is + // exactly what the user banned (see «Direction asymmetry» on + // the hook). The downward half (closed → peek) stays on body. + // close-peek and form-close are also upward, but their commit + // target is `closed` — they're the «retract» gestures the user + // explicitly wants to keep on the body, so they pass through. + if (transition === 'closed-free' && direction === 'up') { + startX = null; + startY = null; + direction = null; + transition = null; + return; + } } engaged = true; @@ -238,31 +276,33 @@ export function useCurtainBodyGesture({ let atCommit = false; switch (transition) { 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 <= 0 - ? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD - : lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD; + // Body-side `closed-free` is DOWN-only: the handle owns the + // UP half (pin commit) per «Direction asymmetry» above, and + // the closed-free up-bail in the dead-zone block makes sure + // we only ever engage this branch with direction='down'. + // Clamp at 0 below so a mid-gesture finger-up past the + // start point can't drag the curtain into pin territory + // and offer a pin commit that the user explicitly didn't + // want exposed on the body. Rubber-banded 0.65× + // displacement matches the «physically heavier» body feel. + lastDelta = Math.max(0, delta * RUBBER_BAND); + atCommit = 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. + // damping. Commit target is `closed`; no path into pin + // territory (the user's hard rule — pin is handle-only). lastDelta = delta * RUBBER_BAND; atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD; break; case 'form-close': // Rubber-banded up; capped at 0 so an accidental downward // jitter doesn't push the curtain below its form-snap top. + // Commit target is `closed` (the form-close drag retracts + // through the form's vertical footprint into the closed + // snap). lastDelta = Math.min(0, delta * RUBBER_BAND); atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX; break; @@ -296,12 +336,11 @@ export function useCurtainBodyGesture({ } switch (transition) { 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 if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) { + // Body is DOWN-only — peek is the sole commit target. Pin + // commit lives on the handle (see «Direction asymmetry» + // above and the touchmove switch). Below threshold the + // curtain springs back to closed. + if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) { commitRef.current('peek'); } else { setLiveDrag(0, false); @@ -323,9 +362,11 @@ export function useCurtainBodyGesture({ break; case 'pinned-free': case null: - // Both unreachable per the touchmove switch above; the - // setLiveDrag fallback preserves spring-back behaviour if a - // future change exposes either path here. + // Both unreachable per the touchmove switch above (pinned + // bail at touchstart filters pinned-free; `engaged` only + // flips once `transition` is non-null). The setLiveDrag + // fallback preserves spring-back behaviour if a future + // change exposes either path here. setLiveDrag(0, false); break; default: { @@ -373,8 +414,8 @@ export function useCurtainBodyGesture({ } }; // setLiveDrag is stable; the ref args are stable. `snap`, `pinned`, - // `setPinned` and `commit` are ref-mirrored. Only `disabled` needs - // to tear listeners down — it's the sole effect dep. + // 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, bottomPinnedRef, scrollRef, setLiveDrag, disabled]); }