feat(stream-header): swap the curtain search-peek for a pull-to-refresh dancing mascot with a radar-pulse ripple

This commit is contained in:
heaven 2026-06-08 16:08:50 +03:00
parent 717928154b
commit 901bec2e9a
17 changed files with 517 additions and 420 deletions

View file

@ -446,6 +446,7 @@
"segment_dm": "Direct", "segment_dm": "Direct",
"segment_channels": "Channels", "segment_channels": "Channels",
"segment_bots": "Robots", "segment_bots": "Robots",
"refreshing": "Refreshing…",
"self_row_label": "You", "self_row_label": "You",
"self_row_preview": "Settings & profile", "self_row_preview": "Settings & profile",
"message_me_label": "me", "message_me_label": "me",

View file

@ -448,6 +448,7 @@
"segment_dm": "Личные", "segment_dm": "Личные",
"segment_channels": "Каналы", "segment_channels": "Каналы",
"segment_bots": "Роботы", "segment_bots": "Роботы",
"refreshing": "Обновляем…",
"self_row_label": "Я", "self_row_label": "Я",
"self_row_preview": "Настройки и профиль", "self_row_preview": "Настройки и профиль",
"message_me_label": "я", "message_me_label": "я",

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

View file

@ -6,7 +6,7 @@ import { color } from 'folds';
// horizontally; `align-items: stretch` on the parent fills vertically. // horizontally; `align-items: stretch` on the parent fills vertically.
// //
// `touch-action: pan-y` lets the browser keep doing native vertical // `touch-action: pan-y` lets the browser keep doing native vertical
// scroll (DM list virtualizer, curtain peek pull-down) without us // scroll (DM list virtualizer, curtain pull-to-refresh) without us
// having to call preventDefault on every move — only the pager's own // having to call preventDefault on every move — only the pager's own
// listener calls preventDefault, and only after axis-resolve commits // listener calls preventDefault, and only after axis-resolve commits
// to "horizontal". // to "horizontal".
@ -93,7 +93,7 @@ export const pagerRoot = style({
// top in the safe-top + tabsRow band — the void is contained to // top in the safe-top + tabsRow band — the void is contained to
// the carve area, tabs stay visible. The horseshoe's `appBody` // the carve area, tabs stay visible. The horseshoe's `appBody`
// flips back to opaque on the same signal so the void doesn't // flips back to opaque on the same signal so the void doesn't
// bleed into the chip-area band between the static header and // bleed into the mascot/form band between the static header and
// the curtain top either. The curtain pin gesture is gated off // the curtain top either. The curtain pin gesture is gated off
// in the same state (see `StreamHeader.gestureDisabled`) so no // in the same state (see `StreamHeader.gestureDisabled`) so no
// pin can race the elevation flip. // pin can race the elevation flip.

View file

@ -1,32 +0,0 @@
import React from 'react';
import { Icon, IconSrc } from 'folds';
import * as css from './StreamHeader.css';
type ChipProps = {
iconSrc: IconSrc;
label: string;
onClick: () => void;
// When the curtain covers the chip its row is `height: 0` /
// `overflow: hidden`. We also flip `tabIndex` so keyboard users
// can't focus the invisible button on desktop (where peek-drag is
// unavailable). Re-enabled when the row is revealed.
hidden: boolean;
};
// Pill-shaped reveal button shown when the user drags the curtain down
// to a peek stage. Same geometry as the inline form's input bar so the
// transition chip → input feels like a content swap, not a layout move.
export function Chip({ iconSrc, label, onClick, hidden }: ChipProps) {
return (
<button
type="button"
onClick={onClick}
className={css.chip}
tabIndex={hidden ? -1 : 0}
aria-hidden={hidden || undefined}
>
<Icon size="50" src={iconSrc} />
<span className={css.chipPlaceholder}>{label}</span>
</button>
);
}

View file

@ -0,0 +1,85 @@
import React, { useMemo } from 'react';
import { Text } from 'folds';
import { isNativePlatform } from '../../utils/capacitor';
// Dancing-mascot pull-to-refresh asset — the pink "evil mascot" working
// out (dumbbells + radar rings), cut to a seamless 2s loop, background
// keyed to alpha, VP9. SEPARATE from the auth `mascot.*` (the purple
// idle mascot), which is unchanged.
import mascotPoster from '../../../../public/res/img/mascot-dance.png';
import mascotWebm from '../../../../public/res/img/mascot-dance.webm';
import * as css from './StreamHeader.css';
type RefreshMascotProps = {
// Caption under the mascot («Обновляем…»). Passed by StreamHeader so the
// copy stays in the i18n layer rather than baked in here.
caption: string;
// True while the curtain is revealing — or resting at — the refresh card
// (refresh snap, or a live down-drag from closed). Only then do we mount
// the looping <video>; otherwise (curtain closed, card z-occluded) we
// render the still poster so the decoder isn't running behind the
// curtain 24/7. The card is z-covered + aria-hidden at rest, and the
// poster is the clip's first frame, so the poster↔video swap is
// invisible and the dance starts seamlessly on reveal.
revealing: boolean;
};
// The card revealed below the tabs row by the curtain's `refresh` snap:
// a looping dancing mascot, painted in the header's `SurfaceVariant.
// Container` tone. The mascot dances during the pull-to-refresh; no
// caption — the animation is the whole affordance (Claude-mobile-app
// style).
//
// We render a plain `<video>` — NOT the auth `mascotSingleton`
// currentTime-preservation machinery, which solves a cold-boot remount
// problem this surface doesn't have; the two mascot surfaces stay
// decoupled by design.
//
// Native-only: the curtain pull-to-refresh gesture is native-only (web
// has no drag), so the card would never be revealed on web — skip
// mounting it there entirely.
//
// `prefers-reduced-motion`: fall back to the still poster so the reveal
// honours the OS motion-reduction setting. (Read once at mount; the rest
// of the app reads this query one-shot too, and this card is native-only
// + aria-hidden, so a mid-session OS toggle not re-evaluating here is
// acceptable.)
export function RefreshMascot({ caption, revealing }: RefreshMascotProps) {
const native = isNativePlatform();
const reducedMotion = useMemo(
() =>
typeof window !== 'undefined' &&
!!window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
[]
);
if (!native) return null;
const showVideo = revealing && !reducedMotion;
return (
<div className={css.mascotCard} aria-hidden="true">
{/* The radar-pulse rings live at the header level (StreamHeader), not
here they break out of this «formal» card and ripple across the
whole light-blue chrome. This card holds just the mascot + caption. */}
{showVideo ? (
<video
className={css.mascotMedia}
src={mascotWebm}
poster={mascotPoster}
autoPlay
loop
muted
playsInline
/>
) : (
<img className={css.mascotMedia} src={mascotPoster} alt="" />
)}
<Text className={css.mascotCaption} size="T200" priority="300">
{caption}
</Text>
</div>
);
}

View file

@ -1,13 +1,13 @@
import { style } from '@vanilla-extract/css'; import { keyframes, style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes'; import { recipe } from '@vanilla-extract/recipes';
import { color, config, toRem } from 'folds'; import { color, config, toRem } from 'folds';
import { import {
CHIP_GAP_PX,
CURTAIN_BREATHER_PX,
CURTAIN_RADIUS_PX, CURTAIN_RADIUS_PX,
CURTAIN_SNAP_EASING, CURTAIN_SNAP_EASING,
CURTAIN_SNAP_MS, CURTAIN_SNAP_MS,
HANDLE_HEIGHT_PX, HANDLE_HEIGHT_PX,
MASCOT_CARD_PX,
MASCOT_VIDEO_PX,
TABS_ROW_PX, TABS_ROW_PX,
WEB_TABS_ROW_PX, WEB_TABS_ROW_PX,
} from './geometry'; } from './geometry';
@ -36,15 +36,16 @@ export const stage = style({
}, },
}); });
// Header — always-rendered strip carrying tabs row + (optional) chip // Header — always-rendered strip carrying tabs row + (optional)
// reveal area + (optional) active form. The curtain slides on top of // mascot-refresh card + (optional) active form. The curtain slides on
// the area BELOW the tabs row to cover/reveal those children. // top of the area BELOW the tabs row to cover/reveal those children.
// //
// In pager mode the bg collapses to transparent for the same reason as // In pager mode the bg collapses to transparent for the same reason as
// `stage` above — let the static pager header show through where the // `stage` above — let the static pager header show through where the
// curtain isn't. Chips have their own pill bg and the inline form is // curtain isn't. The mascot card carries its own `SurfaceVariant`
// composed of folds-styled inputs with their own backgrounds, so the // backdrop and the inline form is composed of folds-styled inputs with
// peek/form snaps stay visually opaque without this layer. // their own backgrounds, so the refresh/form snaps stay visually opaque
// without this layer.
export const header = style({ export const header = style({
position: 'absolute', position: 'absolute',
top: 0, top: 0,
@ -133,7 +134,7 @@ export const iconsCluster = style({
// rendered position — disabled during drag, restored on commit. // rendered position — disabled during drag, restored on commit.
// //
// Web variant (`[data-platform="web"]` on `stage`, set by // Web variant (`[data-platform="web"]` on `stage`, set by
// StreamHeader.tsx when `!isNativePlatform()`): there is no pin/peek // StreamHeader.tsx when `!isNativePlatform()`): there is no pin/refresh
// gesture, so the curtain is a purely static slab under the tabs row. // gesture, so the curtain is a purely static slab under the tabs row.
// Drop ONLY the «card» rounding (top corners flat). The divider rule // Drop ONLY the «card» rounding (top corners flat). The divider rule
// at the seam is owned by `tabsRow.borderBottom` under the same // at the seam is owned by `tabsRow.borderBottom` under the same
@ -158,8 +159,7 @@ export const curtain = style({
borderTopLeftRadius: toRem(CURTAIN_RADIUS_PX), borderTopLeftRadius: toRem(CURTAIN_RADIUS_PX),
borderTopRightRadius: toRem(CURTAIN_RADIUS_PX), borderTopRightRadius: toRem(CURTAIN_RADIUS_PX),
transition: `top ${CURTAIN_SNAP_MS}ms ${CURTAIN_SNAP_EASING}`, transition: `top ${CURTAIN_SNAP_MS}ms ${CURTAIN_SNAP_EASING}`,
// Hint the compositor while the curtain is moving. Cheap since the // Hint the compositor while the curtain is moving.
// curtain is the only element in this stacking context that animates.
willChange: 'top', willChange: 'top',
selectors: { selectors: {
'[data-platform="web"] &': { '[data-platform="web"] &': {
@ -295,62 +295,93 @@ export const segmentDot = recipe({
}, },
}); });
// Chip row — outer clip-strip. Both rows reveal together when the // Dancing-mascot pull-to-refresh card. Revealed below the tabs row when
// user drags the curtain down to the `peek` snap. // the user drags the curtain down to the `refresh` snap. Its fixed
// // height equals `MASCOT_CARD_PX`, which the curtain's refresh rest-top
// The `marginBottom` math is load-bearing for the snap-top // (`TABS_ROW_PX + MASCOT_CARD_PX`) lands exactly on — so the card edge
// calculation: the resting `top` of `peek` lands the curtain exactly // sits at the curtain's rounded top with the content centred above it.
// where the next row would have begun, so the breather never "steals" export const mascotCard = style({
// pixels from the next chip's paddingTop. Two different values: flexShrink: 0,
// - default (chip-to-chip): `CHIP_GAP_PX` — tighter, so the two height: toRem(MASCOT_CARD_PX),
// pills read as a related pair when both are revealed.
// - `:last-child` (chip-to-curtain): `CURTAIN_BREATHER_PX` — wider,
// so the curtain's rounded top has comfortable air above the
// chip pill it lands above.
export const chipRow = style({
height: toRem(56),
marginBottom: toRem(CHIP_GAP_PX),
paddingLeft: toRem(24),
paddingRight: toRem(24),
paddingTop: toRem(8),
display: 'flex',
alignItems: 'flex-start',
selectors: {
'&:last-child': {
marginBottom: toRem(CURTAIN_BREATHER_PX),
},
},
});
// The chip pill itself.
export const chip = style({
appearance: 'none',
border: 'none',
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
gap: toRem(10), justifyContent: 'center',
width: '100%', gap: toRem(2),
height: toRem(48), // Own opaque backdrop so the refresh reveal stays solid in pager mode,
padding: `${toRem(8)} ${toRem(14)}`, // where the `header` bg collapses to transparent (the transparent
borderRadius: toRem(20), // mascot video would otherwise show the static pager header through it).
font: 'inherit', backgroundColor: color.SurfaceVariant.Container,
fontSize: toRem(14),
textAlign: 'left',
cursor: 'pointer',
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
WebkitAppearance: 'none',
}); });
export const chipPlaceholder = style({ // Radar-pulse overlay. Spans the visible light-blue surface behind the
opacity: 0.65, // curtain — up into the safe-top (lifted in via the negative `top`) and
// down to the stage bottom — so the ripple washes across the app chrome.
// `zIndex: 1` is over the header chrome but under the curtain (`zIndex: 2`),
// which occludes whatever's below its top edge wherever it's dragged.
// `pointerEvents: none` + low opacity keep the tabs / mascot readable.
// Rendered only while revealed + animated (StreamHeader `showPulse`).
const pulseKeyframes = keyframes({
'0%': { transform: 'translate(-50%, -50%) scale(0.05)', opacity: 0 },
'10%': { opacity: 0.26 },
'65%': { opacity: 0.13 },
'100%': { transform: 'translate(-50%, -50%) scale(1)', opacity: 0 },
});
export const stagePulseLayer = style({
position: 'absolute',
top: 'calc(-1 * var(--vojo-safe-top, 0px))',
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 1,
});
export const stagePulse = style({
position: 'absolute',
left: '50%',
// Origin ≈ the mascot centre. The layer top is lifted by the safe-top, so
// add it back: safe-top + tabs row + the card's top pad + half the video
// box (≈ TABS_ROW_PX + 53 with the current card geometry).
top: `calc(var(--vojo-safe-top, 0px) + ${toRem(TABS_ROW_PX + 53)})`,
width: toRem(760),
height: toRem(760),
borderRadius: '50%',
border: `${toRem(2)} solid rgb(248, 80, 160)`,
opacity: 0,
transform: 'translate(-50%, -50%) scale(0.05)',
animationName: pulseKeyframes,
animationDuration: '4s',
animationTimingFunction: 'ease-out',
animationIterationCount: 'infinite',
});
// The looping mascot video (or its still poster under reduced-motion),
// `object-fit: contain` so the whole dance frame fits the box. The box is
// SMALLER than the card height (geometry.ts), so it's centred with
// breathing room and never clipped. `pointerEvents: none` so a drag that
// starts on the card flows to the curtain body gesture rather than being
// eaten by the media element.
export const mascotMedia = style({
width: toRem(MASCOT_VIDEO_PX),
height: toRem(MASCOT_VIDEO_PX),
objectFit: 'contain',
pointerEvents: 'none',
userSelect: 'none',
});
// Caption under the mascot («Обновляем…»). Muted so the dancing mascot
// stays the focus.
export const mascotCaption = style({
opacity: 0.7,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}); });
// Active form area in the header. Outer is `position: relative`; the // Active form area in the header. Outer is `position: relative`; the
// inner mounted form fills it with `top: 0` so the form's first // inner mounted form fills it with `top: 0` so the form's input bar
// element (input bar) sits flush at the line where chips would // sits flush just below the tabs row.
// otherwise live.
export const formArea = style({ export const formArea = style({
position: 'relative', position: 'relative',
flexShrink: 0, flexShrink: 0,
@ -362,14 +393,12 @@ export const formInner = style({
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
// Narrower side padding than the chip rows (24px) so the search form // 12px side padding insets the search bar a little from the column edge.
// uses more of the column width — the inset bar read as «narrow».
// //
// Bottom padding is 0 (not 12 like the top): the form's last child is // Bottom padding is 0 (not 8 like the top): the form's last child is
// the scrollable result list, and it must run flush into the curtain's // the scrollable result list, and it must run flush into the curtain's
// top edge. Any bottom padding here — like the curtain breather the // top edge. Any bottom padding here would reintroduce a light-blue strip
// form snap deliberately drops (see `snapTopPx`) — would reintroduce a // that clips the search results before they reach the curtain. The 8px
// light-blue strip that clips the search results before they reach the // top padding stays: it's the gap below the tabs row.
// curtain. The 8px top padding stays: it's the gap below the tabs row.
padding: `${toRem(8)} ${toRem(12)} 0`, padding: `${toRem(8)} ${toRem(12)} 0`,
}); });

View file

@ -11,9 +11,11 @@ import React, {
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMatch, useNavigate } from 'react-router-dom'; import { useMatch, useNavigate } from 'react-router-dom';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { ClientEvent, SyncState } from 'matrix-js-sdk';
import { Box, Icon, IconButton, Icons, toRem } from 'folds'; import { Box, Icon, IconButton, Icons, toRem } from 'folds';
import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths'; import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths';
import { isNativePlatform } from '../../utils/capacitor'; import { isNativePlatform } from '../../utils/capacitor';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useBotPresets } from '../../features/bots/catalog'; import { useBotPresets } from '../../features/bots/catalog';
import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext'; import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext';
import { import {
@ -21,13 +23,13 @@ import {
StreamHeaderPrimaryAction, StreamHeaderPrimaryAction,
mobilePagerCurtainAtom, mobilePagerCurtainAtom,
} from '../../state/mobilePagerHeader'; } from '../../state/mobilePagerHeader';
import { TABS_ROW_PX, WEB_TABS_ROW_PX } from './geometry'; import { REFRESH_MAX_MS, REFRESH_MIN_MS, TABS_ROW_PX, WEB_TABS_ROW_PX } from './geometry';
import { settingsSheetAtom } from '../../state/settingsSheet'; import { settingsSheetAtom } from '../../state/settingsSheet';
import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet'; import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
import * as css from './StreamHeader.css'; import * as css from './StreamHeader.css';
import { Segment } from './Segment'; import { Segment } from './Segment';
import { Chip } from './Chip'; import { RefreshMascot } from './RefreshMascot';
import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState'; import { isFormSnap, isRefreshSnap, snapTopPx, useCurtainState } from './useCurtainState';
import { useCurtainHandleGesture } from './useCurtainHandleGesture'; import { useCurtainHandleGesture } from './useCurtainHandleGesture';
import { useCurtainBodyGesture } from './useCurtainBodyGesture'; import { useCurtainBodyGesture } from './useCurtainBodyGesture';
import { InlineRoomSearch } from './forms/InlineRoomSearch'; import { InlineRoomSearch } from './forms/InlineRoomSearch';
@ -62,8 +64,8 @@ type StreamHeaderProps = {
// listing share `"channels"` so pin survives the toggle between // listing share `"channels"` so pin survives the toggle between
// empty state and a chosen workspace. // empty state and a chosen workspace.
pinKey: string; pinKey: string;
// Optional contextual create action (a Plus button + peek chip). When // Optional contextual create action (a Plus button in the tabs row).
// omitted (Direct) the header shows no Plus at all — starting a chat // When omitted (Direct) the header shows no Plus at all — starting a chat
// is folded into search, which finds people via the homeserver user // is folded into search, which finds people via the homeserver user
// directory. Channels sets this to «create channel» / «create // directory. Channels sets this to «create channel» / «create
// community» so its Plus launches that flow. // community» so its Plus launches that flow.
@ -120,12 +122,8 @@ export function StreamHeader({
else navigate(BOTS_PATH, navOpts); else navigate(BOTS_PATH, navOpts);
}, [selectTabInstant, navigate, navOpts]); }, [selectTabInstant, navigate, navOpts]);
// Peek reveals a single Search chip by default (Direct — new-chat is const curtain = useCurtainState(pinKey);
// gone, people are found through search), or two when a `primaryAction` const mx = useMatrixClient();
// adds the tab's contextual create chip (Channels). The count drives
// the curtain's peek travel geometry.
const chipRows = primaryAction ? 2 : 1;
const curtain = useCurtainState(pinKey, chipRows);
// Suppress every curtain gesture whenever the user is interacting // Suppress every curtain gesture whenever the user is interacting
// with something else that would otherwise race the pin path: // with something else that would otherwise race the pin path:
@ -153,8 +151,8 @@ export function StreamHeader({
// * `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. From // at the top of the curtain. Crisp 1:1 finger ↔ curtain. From
// closed the gesture is a free-range drag spanning pin↔closed↔ // closed the gesture is a free-range drag spanning pin↔closed↔
// peek in one motion (`closed-free`); other snaps drive single- // refresh in one motion (`closed-free`); other snaps drive single-
// destination transitions (unpin / close-peek / form-close). // destination transitions (unpin / close-refresh / 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. Only rendered on native (`isNativePlatform()`). // scroll. Only rendered on native (`isNativePlatform()`).
@ -190,7 +188,6 @@ export function StreamHeader({
snap: curtain.snap, snap: curtain.snap,
pinned: curtain.pinned, pinned: curtain.pinned,
setPinned: curtain.setPinned, setPinned: curtain.setPinned,
peekTravelPx: curtain.peekTravelPx,
setLiveDrag: curtain.setLiveDrag, setLiveDrag: curtain.setLiveDrag,
commit: curtain.commit, commit: curtain.commit,
disabled: gestureDisabled, disabled: gestureDisabled,
@ -203,7 +200,6 @@ export function StreamHeader({
scrollRef, scrollRef,
snap: curtain.snap, snap: curtain.snap,
pinned: curtain.pinned, pinned: curtain.pinned,
peekTravelPx: curtain.peekTravelPx,
setLiveDrag: curtain.setLiveDrag, setLiveDrag: curtain.setLiveDrag,
commit: curtain.commit, commit: curtain.commit,
disabled: gestureDisabled, disabled: gestureDisabled,
@ -214,6 +210,97 @@ export function StreamHeader({
const openSearch = useCallback(() => curtain.open(), [curtain]); const openSearch = useCallback(() => curtain.open(), [curtain]);
const { close } = curtain; const { close } = curtain;
// Radar-pulse overlay gate. Native-only (the gesture is), silent under
// reduced-motion, and shown whenever the refresh card is revealed — at
// the `refresh` snap OR while a drag is in flight (`isDragging`).
//
// We gate on `isDragging` (a stable boolean held true for the whole drag)
// NOT on `liveDragPx > 0`: a held finger micro-jitters around the drag
// origin, so `liveDragPx` dithers across 0 every frame. With the old
// `liveDragPx > 0` term that flipped `showPulse` true/false each frame,
// remounting the pulse layer (and the mascot video) → the rings restarted
// over and over ("circle spam") and the curtain rattled. `isDragging`
// doesn't flip on the drag delta's sign, so the layer stays mounted.
const reducedMotion = useMemo(
() =>
typeof window !== 'undefined' &&
!!window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
[]
);
const showPulse =
isNativePlatform() && !reducedMotion && (isRefreshSnap(curtain.snap) || curtain.isDragging);
// Pull-to-refresh side-effect. When the curtain commits to the
// `refresh` snap, keep the mascot dancing for at least REFRESH_MIN_MS
// (so a near-instant result doesn't flash), capped at REFRESH_MAX_MS
// (so an offline client can't strand the curtain open), then auto-close.
//
// Matrix /sync is a continuous long-poll, so on a HEALTHY client there's
// nothing to wait for — the state is already current and the next
// `Syncing` event may be up to a full timeout away. So we branch on the
// current sync state: healthy (Syncing/Prepared) → the pull is a ~1.2s
// delight, state already fresh; otherwise (Error/Reconnecting/Catchup/
// cold) → `mx.retryImmediately()` forces the next /sync now and we wait
// for the recovery `Syncing`/`Prepared` event (same poke ClientRoot uses
// on the `online` event). An early up-drag dismiss (`close-refresh`)
// flips `refreshing` false and the cleanup cancels the pending timers.
//
// The "ready to close" signal (`refreshReady`) is split from the actual
// close so the close can DEFER while a drag is in flight. Closing during
// a drag would zero `liveDragPx` under the finger while the gesture hook
// keeps its own `lastDelta`, snapping the curtain ~MASCOT_CARD_PX on the
// next touchmove. The separate close effect below gates on
// `!curtain.isDragging`, so a deferred close fires the instant a
// mid-refresh retract drag releases (spring-back included — no strand).
const refreshing = curtain.snap === 'refresh';
const [refreshReady, setRefreshReady] = useState(false);
useEffect(() => {
if (!refreshing) {
setRefreshReady(false);
return undefined;
}
let synced = false;
let minElapsed = false;
const update = () => {
if (synced && minElapsed) setRefreshReady(true);
};
const state = mx.getSyncState();
if (state === SyncState.Syncing || state === SyncState.Prepared) {
// Already healthy — state is current; just run the minimum dance.
synced = true;
} else {
// Stuck/cold — force the next /sync and wait for recovery below.
mx.retryImmediately();
}
const onSync = (next: SyncState) => {
if (next === SyncState.Syncing || next === SyncState.Prepared) {
synced = true;
update();
}
};
mx.on(ClientEvent.Sync, onSync);
const minTimer = window.setTimeout(() => {
minElapsed = true;
update();
}, REFRESH_MIN_MS);
// Hard cap: force-ready even if /sync never recovers (offline client).
const maxTimer = window.setTimeout(() => setRefreshReady(true), REFRESH_MAX_MS);
return () => {
mx.removeListener(ClientEvent.Sync, onSync);
window.clearTimeout(minTimer);
window.clearTimeout(maxTimer);
};
}, [refreshing, mx]);
// Close once the dance is ready AND no drag is in flight. Re-runs when
// `isDragging` flips, so a close deferred during a mid-refresh retract
// fires the moment the finger releases (whether it sprang back or not).
const refreshDragging = curtain.isDragging;
useEffect(() => {
if (refreshing && refreshReady && !refreshDragging) close();
}, [refreshing, refreshReady, refreshDragging, close]);
// Memoised controls object so the cleanup's identity check (atom // Memoised controls object so the cleanup's identity check (atom
// compare-and-clear) is meaningful — without useMemo a fresh object // compare-and-clear) is meaningful — without useMemo a fresh object
// would be created on every render and the cleanup of an earlier // would be created on every render and the cleanup of an earlier
@ -246,7 +333,7 @@ export function StreamHeader({
// commit happen in the same render pipeline and there's no // commit happen in the same render pipeline and there's no
// intermediate "snap back, then animate" flash on release. // intermediate "snap back, then animate" flash on release.
// //
// When `pinned` is true the local snap (kept at {closed, peek, // When `pinned` is true the local snap (kept at {closed, refresh,
// form-*}) is overridden — the curtain rests at y = 0 inside the // form-*}) is overridden — the curtain rests at y = 0 inside the
// stage (= y = safe-top in viewport), covering the tabs row. The // stage (= y = safe-top in viewport), covering the tabs row. The
// global pinned atom shares this state across every listing tab so // global pinned atom shares this state across every listing tab so
@ -256,7 +343,7 @@ export function StreamHeader({
// snap by the delta between native and web tabs-row heights. Tabs // snap by the delta between native and web tabs-row heights. Tabs
// row on web is `WEB_TABS_ROW_PX` (= 54px, matching PageHeader on // row on web is `WEB_TABS_ROW_PX` (= 54px, matching PageHeader on
// the right pane); `snapTopPx` is computed against `TABS_ROW_PX` // the right pane); `snapTopPx` is computed against `TABS_ROW_PX`
// (= 64px) which stays authoritative for native pin/peek geometry. // (= 64px) which stays authoritative for native pin/refresh geometry.
// Subtracting the delta on web realigns the closed/form snaps with // Subtracting the delta on web realigns the closed/form snaps with
// the smaller tabs row without touching the snap-state machine. // the smaller tabs row without touching the snap-state machine.
// Pinned (= 0) doesn't need the offset because the safe-top + native // Pinned (= 0) doesn't need the offset because the safe-top + native
@ -264,9 +351,7 @@ export function StreamHeader({
const platformOffset = isNativePlatform() ? 0 : WEB_TABS_ROW_PX - TABS_ROW_PX; const platformOffset = isNativePlatform() ? 0 : WEB_TABS_ROW_PX - TABS_ROW_PX;
const curtainTop = curtain.pinned const curtainTop = curtain.pinned
? 0 + curtain.liveDragPx ? 0 + curtain.liveDragPx
: snapTopPx(curtain.snap, curtain.formHeightPx, curtain.peekTravelPx) + : snapTopPx(curtain.snap, curtain.formHeightPx) + platformOffset + curtain.liveDragPx;
platformOffset +
curtain.liveDragPx;
// After the curtain settles at `closed`, unmount any lingering form. // After the curtain settles at `closed`, unmount any lingering form.
// Guarded so unrelated transitionend events (e.g. children's own // Guarded so unrelated transitionend events (e.g. children's own
@ -435,19 +520,14 @@ export function StreamHeader({
)} )}
</div> </div>
{/* Chips vs form {/* Mascot-refresh card vs search form
Mutually exclusive. While a form is mounted (including the Mutually exclusive. While the search form is mounted
curtain's close-snap window before `acknowledgeClosed`), the (including the curtain's close-snap window before
chips stay unrendered so the form doesn't visually jump `acknowledgeClosed`), the mascot card stays unrendered so the
from y = TABS_ROW_PX to y = TABS_ROW_PX + 2·CHIP_ROW_PX form doesn't visually jump mid-animation. Otherwise the mascot
mid-animation. card sits in its fixed header position below the tabs row and
the curtain occludes it by z-stacking; dragging the curtain
When chips are rendered: always present in their fixed down to `refresh` reveals it from underneath. */}
header positions; the curtain occludes them by z-stacking.
As the user drags the curtain down, the chips reveal from
underneath naturally. `Chip.hidden` only controls keyboard
focus (the chip paints normally; the curtain's z-index does
the visual hiding). */}
{curtain.activeForm ? ( {curtain.activeForm ? (
<div <div
id={INLINE_FORM_ID} id={INLINE_FORM_ID}
@ -463,32 +543,33 @@ export function StreamHeader({
</div> </div>
</div> </div>
) : ( ) : (
<> // Dancing-mascot pull-to-refresh card. Rendered in the header
<div className={css.chipRow}> // below the tabs row; the curtain z-occludes it at `closed` and
<Chip // the drag-down reveals it at the `refresh` snap. The Search /
iconSrc={Icons.Search} // Channels-create chips that used to live here are gone — search
label={t('Search.search')} // is reached via the tabs-row icon, Channels-create via its
onClick={openSearch} // tabs-row Plus.
hidden={curtain.snap !== 'peek'} <RefreshMascot
caption={t('Direct.refreshing')}
revealing={isRefreshSnap(curtain.snap) || curtain.isDragging}
/> />
</div>
{/* Second chip is the tab's contextual create action
(Channels). Direct has none its new-chat Plus is gone,
so the peek reveals a single Search chip. */}
{primaryAction && (
<div className={css.chipRow}>
<Chip
iconSrc={primaryAction.iconSrc}
label={primaryAction.label}
onClick={primaryAction.onClick}
hidden={curtain.snap !== 'peek'}
/>
</div>
)}
</>
)} )}
</header> </header>
{/* Radar-pulse overlay
Concentric pink rings rippling across the light-blue surface behind
the curtain (z:1, the curtain at z:2 occludes whatever's below its
top edge). 10 rings staggered across the 4s loop for a dense,
continuous ripple; `pointerEvents: none` so taps fall through to the
tabs. See `stagePulseLayer`. */}
{showPulse && (
<div className={css.stagePulseLayer}>
{[0, 0.4, 0.8, 1.2, 1.6, 2.0, 2.4, 2.8, 3.2, 3.6].map((delay) => (
<span key={delay} className={css.stagePulse} style={{ animationDelay: `${delay}s` }} />
))}
</div>
)}
{/* Curtain layer {/* Curtain layer
Renders ABOVE the header (z-index higher). `top` combines the Renders ABOVE the header (z-index higher). `top` combines the
snap-derived resting position with the live finger drag one snap-derived resting position with the live finger drag one
@ -513,7 +594,7 @@ export function StreamHeader({
sit flush against the curtain's rounded top. sit flush against the curtain's rounded top.
On native the handle hosts the authoritative curtain On native the handle hosts the authoritative curtain
gesture (pin / unpin / peek / close-peek / form-close) gesture (pin / unpin / refresh / close-refresh / form-close)
and stays mounted across snap transitions so the gesture and stays mounted across snap transitions so the gesture
surface is always reachable when there is one to make. surface is always reachable when there is one to make.

View file

@ -219,8 +219,7 @@ export function InlineRoomSearch({ onClose }: Props) {
// resting top and the pre-measure fallback in lockstep. // resting top and the pre-measure fallback in lockstep.
style={{ width: '100%', height: toRem(SEARCH_FORM_BASE_PX - 8) }} style={{ width: '100%', height: toRem(SEARCH_FORM_BASE_PX - 8) }}
> >
{/* ── Input bar (matches chip geometry: h=48 / r=20 / pad 8/14) {/* ── Input bar (h=48 / r=20 / pad 8/14 pill).
so the chip input morph reads as a content crossfade.
`width: 100%` + `minWidth: 0` keep the bar a constant full `width: 100%` + `minWidth: 0` keep the bar a constant full
width whether empty or typed without it the flex row width whether empty or typed without it the flex row
collapses to its content (the search icon) until text widens collapses to its content (the search icon) until text widens

View file

@ -4,9 +4,9 @@
// Mental model: the chats card is a curtain layered ABOVE the header // Mental model: the chats card is a curtain layered ABOVE the header
// (z-index higher). The curtain's `top` is the visible part of the // (z-index higher). The curtain's `top` is the visible part of the
// header below the always-pinned tabs row. When the curtain is fully // header below the always-pinned tabs row. When the curtain is fully
// closed it sits flush under the tabs row (covering chips + form area // closed it sits flush under the tabs row (covering the mascot card +
// beneath). Dragging it DOWN reveals more of the header from underneath. // form area beneath). Dragging it DOWN reveals more of the header from
// Dragging UP raises the curtain back over the header. // underneath. Dragging UP raises the curtain back over the header.
// //
// Snap stops (curtain.top, px): // Snap stops (curtain.top, px):
// pinned = 0 (curtain sits flush at top of the stage, tabs row // pinned = 0 (curtain sits flush at top of the stage, tabs row
@ -14,8 +14,9 @@
// stage stays painted by the surrounding context — // stage stays painted by the surrounding context —
// see «pinned visual contract» below) // see «pinned visual contract» below)
// closed = TABS_ROW_PX // closed = TABS_ROW_PX
// peek = TABS_ROW_PX + 2·CHIP_ROW_PX + CHIP_GAP_PX // refresh = TABS_ROW_PX + MASCOT_CARD_PX (dancing-mascot pull-to-
// + CURTAIN_BREATHER_PX // refresh card fully revealed; a MOMENTARY snap — entering
// it pokes /sync and auto-closes once sync returns)
// form:* = TABS_ROW_PX + formHeight (NO breather — the search // form:* = TABS_ROW_PX + formHeight (NO breather — the search
// results clip flush at the curtain's top edge; the // results clip flush at the curtain's top edge; the
// form's own height already reaches the curtain) // form's own height already reaches the curtain)
@ -52,41 +53,31 @@ export const TABS_ROW_PX = 64;
// 1. CSS `[data-platform="web"] &` selectors on `tabsRow` (height // 1. CSS `[data-platform="web"] &` selectors on `tabsRow` (height
// → `WEB_TABS_ROW_PX`, plus the divider as `borderBottom`). // → `WEB_TABS_ROW_PX`, plus the divider as `borderBottom`).
// 2. TSX `platformOffset = WEB_TABS_ROW_PX - TABS_ROW_PX` (= -10) // 2. TSX `platformOffset = WEB_TABS_ROW_PX - TABS_ROW_PX` (= -10)
// added to `curtainTop` so the closed / peek / form snaps all // added to `curtainTop` so the closed / refresh / form snaps all
// ride the reduced tabs row without recomputing `snapTopPx`. // ride the reduced tabs row without recomputing `snapTopPx`.
export const WEB_TABS_ROW_PX = 54; export const WEB_TABS_ROW_PX = 54;
// Each peek-chip row. Reveals one chip's pill (h=48) + 8px top breather. // Dancing-mascot pull-to-refresh card. The curtain's `refresh` snap
export const CHIP_ROW_PX = 56; // rests `MASCOT_CARD_PX` below the tabs row, revealing the card: the
// mascot above a short caption («Обновляем…»), stacked in a centred
// Vertical gap BETWEEN two consecutive chip rows. Separate from // column.
// `CURTAIN_BREATHER_PX` so the inter-chip spacing can read tighter //
// than the breather between the last chip and the curtain's rounded // The mascot fits ENTIRELY inside the card (no clipping). The dance clip
// top (the curtain's straight edge against a chip pill needs more // fills ~88% of its 384² frame (the radar rings reach near the edges, so
// air to avoid feeling «clamped», while two pills sitting in a // there's little transparent margin to crop), so the `MASCOT_VIDEO_PX`
// vertical stack want to read as a pair). // square video box is kept SMALLER than the card and rendered
export const CHIP_GAP_PX = 14; // `object-fit: contain`, centred — displayed mascot ≈ 0.88 ×
// MASCOT_VIDEO_PX (≈ 84px). `MASCOT_CARD_PX` budgets the box + caption +
// gaps; the frame's ~6% margin adds the breathing room. Painted in the
// header's `SurfaceVariant.Container` tone.
export const MASCOT_VIDEO_PX = 96;
export const MASCOT_CARD_PX = 124;
// Initial estimate for the search form's outer height. The actual // Initial estimate for the search form's outer height. The actual
// height is measured at runtime via ResizeObserver and adapts to the // height is measured at runtime via ResizeObserver and adapts to the
// available viewport so the form never overflows the chats card. // available viewport so the form never overflows the chats card.
export const SEARCH_FORM_BASE_PX = 360; export const SEARCH_FORM_BASE_PX = 360;
// Breathing strip between the bottom of the revealed peek chip(s) and
// the top of the curtain. Painted by the header's `SurfaceVariant.
// Container` (light-blue) so the chip / Create button never visually
// touches the curtain's rounded top — the user reads a chip that sits
// flush with the curtain as «зажатый» rather than two separate
// affordances. Applies to the PEEK snap only (via `peekTravelPx` and
// the `chipRow` last-child margin).
//
// Deliberately NOT applied to the form snap: the search results are a
// scroll list and must clip flush at the curtain's top edge — a light-
// blue strip there reads as «content cut off early», not breathing room
// (see `snapTopPx`'s form-search case). Also not applied at `closed`
// (nothing to breathe to).
export const CURTAIN_BREATHER_PX = 20;
// Curtain snap transition. Tuned tight for an in-app reveal — // Curtain snap transition. Tuned tight for an in-app reveal —
// emphasized-decelerate territory. // emphasized-decelerate territory.
export const CURTAIN_SNAP_MS = 280; export const CURTAIN_SNAP_MS = 280;
@ -96,35 +87,31 @@ export const CURTAIN_SNAP_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
// horseshoe surfaces elsewhere in the app. // horseshoe surfaces elsewhere in the app.
export const CURTAIN_RADIUS_PX = 24; export const CURTAIN_RADIUS_PX = 24;
// Total vertical travel of the curtain between `closed` and `peek` — // Curtain displacement (px) the user must drag DOWN from closed before
// the resting-top delta between the two snaps. Used as the basis for // release for the refresh to commit. Absolute (NOT a fraction of the
// the peek-commit threshold: the user must drag (rubber-banded) at // card height) so the pull-to-refresh trigger distance stays a
// least COMMIT_THRESHOLD × peekTravel before release for the snap to // comfortable thumb-pull regardless of how tall the mascot card is.
// flip. Anything shorter reads as accidental and springs back. // On the rubber-banded body surface (×RUBBER_BAND) the finger pull
// // needed to reach this displacement is correspondingly longer.
// The chip count is DYNAMIC per tab: Direct reveals a single chip export const REFRESH_COMMIT_PX = 88;
// (Search — new-chat is gone, people are found through search), while
// Channels reveals two (Search + the contextual create action). So the
// peek travel is a function of `chipRows`, computed per StreamHeader
// from its actual chip count and threaded into the gesture hooks +
// `snapTopPx` so the rest position and the commit scale stay in
// lockstep (a mismatch would make the curtain overshoot then retract).
export const peekTravelPx = (chipRows: number): number =>
chipRows * CHIP_ROW_PX + Math.max(0, chipRows - 1) * CHIP_GAP_PX + CURTAIN_BREATHER_PX;
// Back-compat default (two-chip peek). The live value is computed per // Refresh dance timing. The mascot stays revealed for AT LEAST
// StreamHeader via `peekTravelPx(chipRows)`. // `REFRESH_MIN_MS` so a near-instant /sync (healthy online client) reads
export const PEEK_TRAVEL_PX = peekTravelPx(2); // as an intentional refresh rather than a flicker; it auto-closes once
// /sync returns AND that minimum has elapsed. `REFRESH_MAX_MS` caps the
// dance so an offline client (sync never returns) can't strand the
// curtain open — it closes regardless after the cap.
export const REFRESH_MIN_MS = 1200;
export const REFRESH_MAX_MS = 8000;
// Touch gesture tuning. RUBBER_BAND dampens finger→curtain motion so // Touch gesture tuning. RUBBER_BAND dampens finger→curtain motion on
// the chip reveal feels resistive; COMMIT_THRESHOLD is the fraction of // the body surface so the refresh pull feels resistive (physically
// the full peek travel the user must cross on release for the snap to // «heavier» than the handle's crisp 1:1). DIRECTION_DEAD_ZONE_PX is
// commit. Tuned high (≈90%) so anything below «дотянул почти до конца» // the finger travel before a drag resolves to up/down.
// reads as accidental and snaps back to `closed`. // ACTIVE_CLOSE_THRESHOLD_PX is the pull-up distance (raw finger px)
// required to close an active form OR dismiss the refresh card early.
export const RUBBER_BAND = 0.65; export const RUBBER_BAND = 0.65;
export const DIRECTION_DEAD_ZONE_PX = 10; export const DIRECTION_DEAD_ZONE_PX = 10;
export const COMMIT_THRESHOLD = 0.9;
// Pull-up distance (raw finger px) required to close an active form.
export const ACTIVE_CLOSE_THRESHOLD_PX = 100; export const ACTIVE_CLOSE_THRESHOLD_PX = 100;
// Total vertical CURTAIN travel for the closed ↔ pinned gesture. // Total vertical CURTAIN travel for the closed ↔ pinned gesture.
@ -139,21 +126,18 @@ export const PIN_TRAVEL_PX = TABS_ROW_PX;
// and springs back to the previous resting snap. // and springs back to the previous resting snap.
// //
// On the handle the up direction is 1:1 with no upper clamp (the // 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 // «closed-free» transition spans the full pin↔closed↔refresh range in
// one gesture and the curtain follows the finger off-screen freely); // one gesture and the curtain follows the finger off-screen freely);
// the committing curtain DISPLACEMENT is still // the committing curtain DISPLACEMENT is `PIN_COMMIT_THRESHOLD ×
// `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` ≈ 61 px — essentially «drag // PIN_TRAVEL_PX` ≈ 61 px — essentially «drag the curtain across the
// the curtain across the full tabs-row height». On the body the same // full tabs-row height». On the body the same displacement is reached
// displacement is reached with a longer finger pull because the body // with a longer finger pull because the body path is rubber-banded
// path is rubber-banded (×0.65). // (×0.65).
// //
// Unpin's clamp is asymmetric — `pinned-free` lower-bounds the live // `pinned-free` (handle-only) lower-bounds the live delta at 0 (no
// delta at 0 (no destination above pinned) but leaves the upper // destination above pinned) and commits unpin at this threshold. The
// direction unclamped so the same gesture can carry the curtain // body never resolves to `pinned-free` — unpin stays a deliberate
// through closed into peek territory in one motion. The handle-only // drag-handle pull.
// contract on unpin means the body never resolves to `pinned-free`,
// so the no-upper-clamp tolerance only applies on the dedicated
// drag-handle.
export const PIN_COMMIT_THRESHOLD = 0.95; export const PIN_COMMIT_THRESHOLD = 0.95;
// Drag-handle hit-zone at the top of the curtain. NATIVE-ONLY: the // Drag-handle hit-zone at the top of the curtain. NATIVE-ONLY: the
@ -163,7 +147,7 @@ export const PIN_COMMIT_THRESHOLD = 0.95;
// decoration and is omitted entirely. // decoration and is omitted entirely.
// //
// On native the handle is the AUTHORITATIVE gesture surface — // On native the handle is the AUTHORITATIVE gesture surface —
// closed-free / unpin / close-peek / form-close all bind here with // closed-free / unpin / close-refresh / form-close all bind here with
// 1:1 finger ↔ curtain tracking, no matter whether the chat list // 1:1 finger ↔ curtain tracking, no matter whether the chat list
// inside the curtain is scrollable. See `useCurtainHandleGesture` // inside the curtain is scrollable. See `useCurtainHandleGesture`
// for the full state machine. // for the full state machine.

View file

@ -1,2 +1,2 @@
export { StreamHeader } from './StreamHeader'; export { StreamHeader } from './StreamHeader';
export { TABS_ROW_PX, CHIP_ROW_PX, CURTAIN_SNAP_MS, CURTAIN_SNAP_EASING } from './geometry'; export { TABS_ROW_PX, CURTAIN_SNAP_MS, CURTAIN_SNAP_EASING } from './geometry';

View file

@ -2,8 +2,8 @@ import { MutableRefObject, useEffect, useRef } from 'react';
import { isNativePlatform } from '../../utils/capacitor'; import { isNativePlatform } from '../../utils/capacitor';
import { import {
ACTIVE_CLOSE_THRESHOLD_PX, ACTIVE_CLOSE_THRESHOLD_PX,
COMMIT_THRESHOLD,
DIRECTION_DEAD_ZONE_PX, DIRECTION_DEAD_ZONE_PX,
REFRESH_COMMIT_PX,
RUBBER_BAND, RUBBER_BAND,
} from './geometry'; } from './geometry';
import { CurtainSnap, isFormSnap } from './useCurtainState'; import { CurtainSnap, isFormSnap } from './useCurtainState';
@ -30,7 +30,7 @@ type Args = {
// DirectSelfRow, WorkspaceFooter). These rows open their own bottom // DirectSelfRow, WorkspaceFooter). These rows open their own bottom
// sheets via vertical drag, so a touch that starts there must NOT // sheets via vertical drag, so a touch that starts there must NOT
// engage the curtain body — otherwise the // engage the curtain body — otherwise the
// user's «pull settings up» gesture would also pin the curtain // user's «pull settings up» gesture would also drive the curtain
// and the two motions would visually fight. `null` is fine (the // and the two motions would visually fight. `null` is fine (the
// surface has no bottomPinned content); the contains() check is // surface has no bottomPinned content); the contains() check is
// optional-chained. // optional-chained.
@ -53,18 +53,14 @@ type Args = {
// by the touchstart bail) — pin / unpin commits are the handle's // by the touchstart bail) — pin / unpin commits are the handle's
// exclusive contract, see «Direction asymmetry» on the hook. // exclusive contract, see «Direction asymmetry» on the hook.
pinned: boolean; pinned: boolean;
// Closed→peek travel for this curtain's chip count (1 chip on Direct,
// 2 on Channels). The peek commit threshold scales off this so the
// gesture matches the actual rest position. Ref-mirrored like `snap`.
peekTravelPx: number;
// Live drag delta sink — feeds the curtain's `top` via React state, // Live drag delta sink — feeds the curtain's `top` via React state,
// no direct DOM writes. // no direct DOM writes.
setLiveDrag: (px: number, dragging: boolean) => void; setLiveDrag: (px: number, dragging: boolean) => void;
// Snap commit. `'peek'` fires from closed-free's down-half; // Snap commit. `'refresh'` fires from closed-free's down-half;
// `'closed'` fires from close-peek and form-close. The pin / unpin // `'closed'` fires from close-refresh and form-close. The pin / unpin
// paths are handle-only and never flip state through this setter // paths are handle-only and never flip state through this setter
// from the body. // from the body.
commit: (next: 'peek' | 'closed') => void; commit: (next: 'refresh' | 'closed') => void;
// Suppress gesture binding entirely. Same conditions as the handle // Suppress gesture binding entirely. Same conditions as the handle
// hook — see StreamHeader's `gestureDisabled`. // hook — see StreamHeader's `gestureDisabled`.
disabled?: boolean; disabled?: boolean;
@ -82,7 +78,7 @@ type Args = {
// Why a second surface? On listing surfaces with content that fits in // Why a second surface? On listing surfaces with content that fits in
// one screen (empty Direct / Bots / Channels states, the ChannelsLanding // one screen (empty Direct / Bots / Channels states, the ChannelsLanding
// CTA, a workspace with few rooms) the user's natural «pull the curtain // CTA, a workspace with few rooms) the user's natural «pull the curtain
// down to peek» gesture happens anywhere on the visible card. // down to refresh» gesture happens anywhere on the visible card.
// Restricting all motion to the 32 px handle on these surfaces felt // Restricting all motion to the 32 px handle on these surfaces felt
// artificial. On the other hand, surfaces with a scrollable list need // artificial. On the other hand, surfaces with a scrollable list need
// their native vertical scroll preserved — so the body gesture is // their native vertical scroll preserved — so the body gesture is
@ -92,8 +88,8 @@ type Args = {
// //
// Direction asymmetry — pinning is handle-only, retracting is shared. // Direction asymmetry — pinning is handle-only, retracting is shared.
// The body engages on: // The body engages on:
// * closed + DOWN → peek (closed-free, down-half only) // * closed + DOWN → refresh (closed-free, down-half only)
// * peek + UP → closed (close-peek) // * refresh + UP → closed (close-refresh)
// * form-* + UP → closed (form-close) // * form-* + UP → closed (form-close)
// The body does NOT engage on: // The body does NOT engage on:
// * closed + UP → would be pin via closed-free's up-half. The // * closed + UP → would be pin via closed-free's up-half. The
@ -101,26 +97,25 @@ type Args = {
// the body made it too easy to accidentally // the body made it too easy to accidentally
// close the directs/channels/bots header by // close the directs/channels/bots header by
// pinning. Pin must be a deliberate gesture on // pinning. Pin must be a deliberate gesture on
// the dedicated pin-handle. After close-peek / // the dedicated pin-handle. After close-refresh /
// form-close lands at `closed`, the curtain // form-close lands at `closed`, the curtain
// can only go further up via the handle. // can only go further up via the handle.
// * pinned + DOWN → unpin / peek-from-pinned. Same rationale: the // * pinned + DOWN → unpin. Same rationale: the pin handle owns the
// pin handle owns the unpin contract too, so an // unpin contract too, so an accidental drag on
// accidental drag on the visible card can't // the visible card can't undo it. Bailed at
// undo it. Bailed at touchstart (see Pinned // touchstart (see Pinned override below).
// override below).
// The asymmetric block on closed+UP is implemented in onTouchMove // The asymmetric block on closed+UP is implemented in onTouchMove
// after the transition resolves — we only bail closed-free's UP half, // 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 // not every upward drag, so close-refresh and form-close still engage
// the body. // on the body.
// //
// Dynamics: all transitions use rubber-band 0.65 (= RUBBER_BAND) so // Dynamics: all transitions use rubber-band 0.65 (= RUBBER_BAND) so
// the body drag feels physically «heavier» than the handle's crisp // the body drag feels physically «heavier» than the handle's crisp
// 1:1 — the user reads the two surfaces as distinct affordances. The // 1:1 — the user reads the two surfaces as distinct affordances. The
// commit math is expressed in CURTAIN displacement (lastDelta), not // commit math is expressed in CURTAIN displacement (lastDelta), not
// raw finger pull, so a body «commit at COMMIT_THRESHOLD × // raw finger pull, so a body «commit at REFRESH_COMMIT_PX» visually
// PEEK_TRAVEL_PX» visually matches a handle commit at the same point — // matches a handle commit at the same point — only the finger pull
// only the finger pull needed to get there differs. // needed to get there differs.
// //
// Form-snap override: when a form is mounted, the chat list under it // Form-snap override: when a form is mounted, the chat list under it
// is mostly hidden but still in DOM with whatever scrollHeight it has. // is mostly hidden but still in DOM with whatever scrollHeight it has.
@ -141,7 +136,6 @@ export function useCurtainBodyGesture({
scrollRef, scrollRef,
snap, snap,
pinned, pinned,
peekTravelPx,
setLiveDrag, setLiveDrag,
commit, commit,
disabled, disabled,
@ -151,8 +145,6 @@ export function useCurtainBodyGesture({
snapRef.current = snap; snapRef.current = snap;
const pinnedRef = useRef<boolean>(pinned); const pinnedRef = useRef<boolean>(pinned);
pinnedRef.current = pinned; pinnedRef.current = pinned;
const peekTravelPxRef = useRef<number>(peekTravelPx);
peekTravelPxRef.current = peekTravelPx;
const commitRef = useRef(commit); const commitRef = useRef(commit);
commitRef.current = commit; commitRef.current = commit;
const setHandleStateRef = useRef(setHandleState); const setHandleStateRef = useRef(setHandleState);
@ -193,7 +185,7 @@ export function useCurtainBodyGesture({
if (target && handleRef.current?.contains(target)) return; if (target && handleRef.current?.contains(target)) return;
// Hand off to the bottomPinned region (DirectSelfRow, // Hand off to the bottomPinned region (DirectSelfRow,
// WorkspaceFooter). Those rows host their own drag-to-open // WorkspaceFooter). Those rows host their own drag-to-open
// bottom sheets — engaging the curtain gesture here would pin // bottom sheets — engaging the curtain gesture here would drive
// the curtain in parallel with the sheet opening, and the two // the curtain in parallel with the sheet opening, and the two
// motions would visually fight. // motions would visually fight.
if (target && bottomPinnedRef.current?.contains(target)) return; if (target && bottomPinnedRef.current?.contains(target)) return;
@ -245,7 +237,7 @@ export function useCurtainBodyGesture({
direction = delta > 0 ? 'down' : 'up'; direction = delta > 0 ? 'down' : 'up';
transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction); transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction);
if (transition === null) { if (transition === null) {
// (snap, pinned, direction) has no valid motion — peek+down, // (snap, pinned, direction) has no valid motion — refresh+down,
// form+down (pinned+up also resolves to null, though the // form+down (pinned+up also resolves to null, though the
// touchstart pinned-bail already filters every pinned // touchstart pinned-bail already filters every pinned
// gesture before we reach here). Bail without preventDefault // gesture before we reach here). Bail without preventDefault
@ -259,8 +251,8 @@ export function useCurtainBodyGesture({
// Closed-free UP-half bail. closed-free is the only transition // Closed-free UP-half bail. closed-free is the only transition
// whose upward direction commits to pin — and pin via body is // whose upward direction commits to pin — and pin via body is
// exactly what the user banned (see «Direction asymmetry» on // exactly what the user banned (see «Direction asymmetry» on
// the hook). The downward half (closed → peek) stays on body. // the hook). The downward half (closed → refresh) stays on body.
// close-peek and form-close are also upward, but their commit // close-refresh and form-close are also upward, but their commit
// target is `closed` — they're the «retract» gestures the user // target is `closed` — they're the «retract» gestures the user
// explicitly wants to keep on the body, so they pass through. // explicitly wants to keep on the body, so they pass through.
if (transition === 'closed-free' && direction === 'up') { if (transition === 'closed-free' && direction === 'up') {
@ -292,16 +284,16 @@ export function useCurtainBodyGesture({
// want exposed on the body. Rubber-banded 0.65× // want exposed on the body. Rubber-banded 0.65×
// displacement matches the «physically heavier» body feel. // displacement matches the «physically heavier» body feel.
lastDelta = Math.max(0, delta * RUBBER_BAND); lastDelta = Math.max(0, delta * RUBBER_BAND);
atCommit = lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD; atCommit = lastDelta >= REFRESH_COMMIT_PX;
break; break;
case 'close-peek': case 'close-refresh':
// Rubber-banded up. No clamp either side — matches the // Rubber-banded up. No clamp either side — matches the
// original list-bound peek feel; a downward jitter past the // original list-bound retract feel; a downward jitter past
// peek snap is visually negligible against the rubber-band // the refresh snap is visually negligible against the rubber-
// damping. Commit target is `closed`; no path into pin // band damping. Commit target is `closed`; no path into pin
// territory (the user's hard rule — pin is handle-only). // territory (the user's hard rule — pin is handle-only).
lastDelta = delta * RUBBER_BAND; lastDelta = delta * RUBBER_BAND;
atCommit = -lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD; atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
break; break;
case 'form-close': case 'form-close':
// Rubber-banded up; capped at 0 so an accidental downward // Rubber-banded up; capped at 0 so an accidental downward
@ -342,18 +334,18 @@ export function useCurtainBodyGesture({
} }
switch (transition) { switch (transition) {
case 'closed-free': case 'closed-free':
// Body is DOWN-only — peek is the sole commit target. Pin // Body is DOWN-only — refresh is the sole commit target. Pin
// commit lives on the handle (see «Direction asymmetry» // commit lives on the handle (see «Direction asymmetry»
// above and the touchmove switch). Below threshold the // above and the touchmove switch). Below threshold the
// curtain springs back to closed. // curtain springs back to closed.
if (lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) { if (lastDelta >= REFRESH_COMMIT_PX) {
commitRef.current('peek'); commitRef.current('refresh');
} else { } else {
setLiveDrag(0, false); setLiveDrag(0, false);
} }
break; break;
case 'close-peek': case 'close-refresh':
if (-lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) { if (-lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX) {
commitRef.current('closed'); commitRef.current('closed');
} else { } else {
setLiveDrag(0, false); setLiveDrag(0, false);

View file

@ -2,23 +2,23 @@ import { MutableRefObject, useEffect, useRef } from 'react';
import { isNativePlatform } from '../../utils/capacitor'; import { isNativePlatform } from '../../utils/capacitor';
import { import {
ACTIVE_CLOSE_THRESHOLD_PX, ACTIVE_CLOSE_THRESHOLD_PX,
COMMIT_THRESHOLD,
DIRECTION_DEAD_ZONE_PX, DIRECTION_DEAD_ZONE_PX,
PIN_COMMIT_THRESHOLD, PIN_COMMIT_THRESHOLD,
PIN_TRAVEL_PX, PIN_TRAVEL_PX,
REFRESH_COMMIT_PX,
} from './geometry'; } from './geometry';
import { CurtainSnap, isFormSnap } from './useCurtainState'; import { CurtainSnap, isFormSnap, isRefreshSnap } from './useCurtainState';
type Args = { type Args = {
// Drag-handle element at the top of the curtain. ALL curtain // Drag-handle element at the top of the curtain. ALL curtain
// gestures bind here — the chat list's scroll viewport is left to // gestures bind here — the chat list's scroll viewport is left to
// native vertical scroll so finger-down inside the list never races // native vertical scroll so finger-down inside the list never races
// a pin / peek / form-close path. Mounted as the first flex child // a pin / refresh / form-close path. Mounted as the first flex child
// of the curtain in StreamHeader.tsx. // of the curtain in StreamHeader.tsx.
handleRef: MutableRefObject<HTMLDivElement | null>; handleRef: MutableRefObject<HTMLDivElement | null>;
// Current snap stop. Mirrored into a ref so the listener (bound // Current snap stop. Mirrored into a ref so the listener (bound
// once per `disabled` flip) reads fresh values without re-attaching. // once per `disabled` flip) reads fresh values without re-attaching.
// Every snap participates: closed → pin/peek, peek → close-peek, // Every snap participates: closed → pin/refresh, refresh → close,
// form-* → form-close, pinned-overlay → unpin. // form-* → form-close, pinned-overlay → unpin.
snap: CurtainSnap; snap: CurtainSnap;
// Per-pane pinned overlay. When true the handle's drag-down path // Per-pane pinned overlay. When true the handle's drag-down path
@ -27,19 +27,15 @@ type Args = {
// Setter for the pinned overlay; called on release once the user's // Setter for the pinned overlay; called on release once the user's
// drag past the commit threshold qualifies the gesture. // drag past the commit threshold qualifies the gesture.
setPinned: (next: boolean) => void; setPinned: (next: boolean) => void;
// Closed→peek travel for this curtain's chip count (1 chip on Direct,
// 2 on Channels). The peek commit threshold scales off this so the
// gesture matches the actual rest position. Ref-mirrored like `snap`.
peekTravelPx: number;
// Setter for the live drag delta during touchmove. The hook reads // Setter for the live drag delta during touchmove. The hook reads
// `liveDragPx` from the parent state too, so React drives the // `liveDragPx` from the parent state too, so React drives the
// curtain's `top` re-render — no direct DOM writes. // curtain's `top` re-render — no direct DOM writes.
setLiveDrag: (px: number, dragging: boolean) => void; setLiveDrag: (px: number, dragging: boolean) => void;
// Snap commit. Called on release for peek / close-peek / form-close // Snap commit. Called on release for refresh / close / form-close
// (the pin / unpin paths flip `pinned` instead). Narrowed to the // (the pin / unpin paths flip `pinned` instead). Narrowed to the
// two non-form destinations the hook ever reaches. Also resets // two non-form destinations the hook ever reaches. Also resets
// liveDragPx + isDragging atomically inside the parent state. // liveDragPx + isDragging atomically inside the parent state.
commit: (next: 'peek' | 'closed') => void; commit: (next: 'refresh' | 'closed') => void;
// Suppress gesture binding entirely. Used to gate motion when a // Suppress gesture binding entirely. Used to gate motion when a
// bottom sheet is open or when this pane is inactive inside the // bottom sheet is open or when this pane is inactive inside the
// swipe pager. // swipe pager.
@ -60,28 +56,24 @@ type Args = {
// and `useCurtainBodyGesture` for the rubber-banded equivalents. // and `useCurtainBodyGesture` for the rubber-banded equivalents.
// //
// `closed-free` is the single free-range transition that spans the // `closed-free` is the single free-range transition that spans the
// full pin↔closed↔peek vertical range in one gesture. From the closed // full pin↔closed↔refresh vertical range in one gesture. From the
// snap, neither direction is locked at the dead-zone: the user can // closed snap, neither direction is locked at the dead-zone: the user
// drag up past the safe-top zone OR down through the chip area in // can drag up past the safe-top zone OR down into the refresh card in
// one motion, and the release decides pin / peek / snap-back based // one motion, and the release decides pin / refresh / snap-back based
// on the final position. The earlier pair of one-shot `pin` and // on the final position. The earlier pair of one-shot `pin` and
// `peek` transitions used a hard «gate» at the start point (each // `refresh` transitions used a hard «gate» at the start point (each
// direction was clamped to one side of 0 once the dead-zone resolved // direction was clamped to one side of 0 once the dead-zone resolved
// the direction) and the user reported this as a regression — drag // the direction) and the user reported this as a regression — drag
// up, then back down, ran into an invisible wall at the closed // up, then back down, ran into an invisible wall at the closed
// position before peek could engage. // position before the down reveal could engage.
// //
// `pinned-free` is the symmetric free-range transition for the // `pinned-free` is the free-range DOWN transition for the pinned
// pinned overlay: from pinned + drag DOWN the curtain follows the // overlay: from pinned + drag DOWN the curtain follows the finger and
// finger all the way through closed into peek territory in one // commits unpin (back to closed) past the threshold. UP is no-op (no
// motion. On release, peek wins if the finger crossed the absolute // destination above pinned). Only the handle resolves to `pinned-free`
// peek planka (PIN_TRAVEL_PX + COMMIT_THRESHOLD × PEEK_TRAVEL_PX — // — the body gesture bails at touchstart while pinned so unpin remains
// the same visual point peek commits at from closed-free), unpin // a deliberate handle pull.
// wins if at least the unpin threshold was reached, otherwise snap export type CurtainTransition = 'closed-free' | 'pinned-free' | 'close-refresh' | 'form-close';
// 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';
// Exhaustive-check helper. Used in the `default` branch of every // Exhaustive-check helper. Used in the `default` branch of every
// switch over `CurtainTransition | null` so that adding a fifth // switch over `CurtainTransition | null` so that adding a fifth
@ -101,16 +93,15 @@ export const assertNeverCurtainTransition = (_value: never): void => {};
// * pinned + UP → no-op (would push the curtain past safe-top // * pinned + UP → no-op (would push the curtain past safe-top
// on commit — no destination above pinned). // on commit — no destination above pinned).
// * pinned + DOWN → pinned-free (HANDLE-only contract — the body // * pinned + DOWN → pinned-free (HANDLE-only contract — the body
// hook bails entirely while pinned so unpin / // hook bails entirely while pinned so unpin
// peek-from-pinned stays a deliberate handle // stays a deliberate handle pull. See
// pull. See
// `useCurtainBodyGesture::onTouchStart`). // `useCurtainBodyGesture::onTouchStart`).
// * closed (any) → closed-free (single transition spanning the // * closed (any) → closed-free (single transition spanning the
// whole pin↔closed↔peek range; direction at // whole pin↔closed↔refresh range; direction at
// the dead-zone matters only for the // the dead-zone matters only for the
// horizontal-bail check). // horizontal-bail check).
// * peek + UP → close-peek (retreat to closed). // * refresh + UP → close-refresh (retreat to closed).
// * peek + DOWN → no-op (nothing lower to reveal). // * refresh + DOWN → no-op (nothing lower to reveal).
// * form-* + UP → form-close. // * form-* + UP → form-close.
// * form-* + DOWN → no-op (form is already the lowest snap). // * form-* + DOWN → no-op (form is already the lowest snap).
export function resolveCurtainTransition( export function resolveCurtainTransition(
@ -120,7 +111,7 @@ export function resolveCurtainTransition(
): CurtainTransition | null { ): CurtainTransition | null {
if (pinned) return direction === 'down' ? 'pinned-free' : null; if (pinned) return direction === 'down' ? 'pinned-free' : null;
if (snap === 'closed') return 'closed-free'; if (snap === 'closed') return 'closed-free';
if (snap === 'peek') return direction === 'up' ? 'close-peek' : null; if (isRefreshSnap(snap)) return direction === 'up' ? 'close-refresh' : null;
if (isFormSnap(snap)) return direction === 'up' ? 'form-close' : null; if (isFormSnap(snap)) return direction === 'up' ? 'form-close' : null;
return null; return null;
} }
@ -130,7 +121,7 @@ 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 (closed-free, pinned-free, close-peek, form-close) // transition (closed-free, pinned-free, close-refresh, form-close)
// with crisp 1:1 finger ↔ curtain tracking regardless of whether // with crisp 1:1 finger ↔ curtain tracking regardless of whether
// the chat list inside the curtain is scrollable. The curtain BODY // the chat list inside the curtain is scrollable. The curtain BODY
// has a parallel gesture (`useCurtainBodyGesture`) with rubber- // has a parallel gesture (`useCurtainBodyGesture`) with rubber-
@ -138,12 +129,11 @@ export function resolveCurtainTransition(
// no scrollable content — so the user can pull the curtain «from // no scrollable content — so the user can pull the curtain «from
// anywhere» on empty / short lists but a real list-scroll is never // anywhere» on empty / short lists but a real list-scroll is never
// hijacked under their finger. The body is also fully inert while // hijacked under their finger. The body is also fully inert while
// pinned, so unpin (and unpin → peek overshoot) stays a deliberate // pinned, so unpin stays a deliberate handle pull.
// handle pull.
// //
// Design rationale: gestures used to bind to the chat list's scroll // Design rationale: gestures used to bind to the chat list's scroll
// viewport directly, which produced repeating «drag-at-scrollTop=0 // viewport directly, which produced repeating «drag-at-scrollTop=0
// hijacks for pin/peek» bugs. Moving every transition onto a // hijacks for pin/reveal» bugs. Moving every transition onto a
// dedicated handle (plus an opt-in body surface that bails on // dedicated handle (plus an opt-in body surface that bails on
// scrollable lists) removes the scroll/gesture race entirely. // scrollable lists) removes the scroll/gesture race entirely.
// //
@ -154,25 +144,21 @@ export function resolveCurtainTransition(
// * closed-free — NO clamps either side. Finger goes off- // * closed-free — NO clamps either side. Finger goes off-
// screen up → curtain follows past safe-top; // screen up → curtain follows past safe-top;
// finger crosses back below the start point → // finger crosses back below the start point →
// curtain continues into peek territory in // curtain continues into the refresh card in
// the same gesture. Direction-aware commit // the same gesture. Direction-aware commit
// on release: pin if pulled UP past // on release: pin if pulled UP past
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, peek // PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, refresh
// if pulled DOWN past COMMIT_THRESHOLD × // if pulled DOWN past REFRESH_COMMIT_PX, else
// PEEK_TRAVEL_PX, else snap back to closed. // snap back to closed.
// * pinned-free — DOWN-only free-range drag from pinned. // * pinned-free — DOWN-only free-range drag from pinned.
// Clamped at 0 below (no destination above // Clamped at 0 below (no destination above
// pinned), NO upper clamp — the finger can // pinned). Release commits unpin past
// carry the curtain through closed into // PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, else
// peek territory in one motion. Release // snaps back to pinned.
// decides peek (lastDelta ≥ PIN_TRAVEL_PX + // * close-refresh — capped at 0 below (no transition lower than
// COMMIT_THRESHOLD × PEEK_TRAVEL_PX), unpin // the refresh card), NO upper clamp (drag past
// (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 // closed into safe-top freely). Commit at
// COMMIT_THRESHOLD × PEEK_TRAVEL_PX. // ACTIVE_CLOSE_THRESHOLD_PX (absolute).
// * form-close — capped at 0 so a downward jitter can't // * form-close — capped at 0 so a downward jitter can't
// push the curtain below its form-snap top, // push the curtain below its form-snap top,
// NO upper clamp. Commit at // NO upper clamp. Commit at
@ -190,7 +176,6 @@ export function useCurtainHandleGesture({
snap, snap,
pinned, pinned,
setPinned, setPinned,
peekTravelPx,
setLiveDrag, setLiveDrag,
commit, commit,
disabled, disabled,
@ -200,8 +185,6 @@ export function useCurtainHandleGesture({
snapRef.current = snap; snapRef.current = snap;
const pinnedRef = useRef<boolean>(pinned); const pinnedRef = useRef<boolean>(pinned);
pinnedRef.current = pinned; pinnedRef.current = pinned;
const peekTravelPxRef = useRef<number>(peekTravelPx);
peekTravelPxRef.current = peekTravelPx;
const setPinnedRef = useRef(setPinned); const setPinnedRef = useRef(setPinned);
setPinnedRef.current = setPinned; setPinnedRef.current = setPinned;
const commitRef = useRef(commit); const commitRef = useRef(commit);
@ -276,8 +259,8 @@ export function useCurtainHandleGesture({
direction = delta > 0 ? 'down' : 'up'; direction = delta > 0 ? 'down' : 'up';
transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction); transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction);
// (snap, pinned, direction) has no valid motion — pinned+up, // (snap, pinned, direction) has no valid motion — pinned+up,
// peek+down, form+down. Bail so the gesture can be re-armed on // refresh+down, form+down. Bail so the gesture can be re-armed
// the next touch sequence; no preventDefault is fired so the // on the next touch sequence; no preventDefault is fired so the
// browser keeps any default behaviour (it would be a no-op // browser keeps any default behaviour (it would be a no-op
// here anyway — the handle has touchAction:none in CSS). // here anyway — the handle has touchAction:none in CSS).
if (transition === null) { if (transition === null) {
@ -297,42 +280,37 @@ export function useCurtainHandleGesture({
let atCommit = false; let atCommit = false;
switch (transition) { switch (transition) {
case 'closed-free': case 'closed-free':
// Single free-range drag spanning pin↔closed↔peek. 1:1 with // Single free-range drag spanning pin↔closed↔refresh. 1:1
// NO clamps either side: the curtain follows the finger off- // with NO clamps either side: the curtain follows the finger
// screen upward (past safe-top) and continuously into peek // off-screen upward (past safe-top) and continuously into the
// territory downward in the same gesture. The release decides // refresh card downward in the same gesture. The release
// pin / peek / snap-back from the final lastDelta. // decides pin / refresh / snap-back from the final lastDelta.
lastDelta = delta; lastDelta = delta;
// Direction-aware atCommit so the grabber pill stretches // Direction-aware atCommit so the grabber pill stretches
// whichever way the user is committing. Pin and peek are // whichever way the user is committing. Pin and refresh are
// sign-exclusive (one branch can't fire simultaneously with // sign-exclusive (one branch can't fire simultaneously with
// the other) so a simple ternary on `lastDelta` suffices. // the other) so a simple ternary on `lastDelta` suffices.
atCommit = atCommit =
lastDelta <= 0 lastDelta <= 0
? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD ? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD
: lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD; : lastDelta >= REFRESH_COMMIT_PX;
break; break;
case 'pinned-free': case 'pinned-free':
// 1:1 down from pinned. Clamped at 0 below (a downward // 1:1 down from pinned. Clamped at 0 below (a downward
// jitter past the start mustn't push the curtain into // jitter past the start mustn't push the curtain into
// safe-top — there's no destination above pinned), NO // safe-top — there's no destination above pinned). Release
// upper clamp — the curtain follows the finger through // commits unpin past the threshold.
// closed into peek territory in one motion.
lastDelta = Math.max(0, delta); 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 'close-peek': case 'close-refresh':
// 1:1 up; delta is negative. Lower-capped at 0 (a downward // 1:1 up; delta is negative. Lower-capped at 0 (a downward
// jitter shouldn't push past the peek snap), NO upper clamp // jitter shouldn't push past the refresh snap), NO upper clamp
// — the curtain follows the finger off-screen freely in the // — the curtain follows the finger off-screen freely in the
// safe-top direction, matching the «drag up off-screen from // safe-top direction, matching the «drag up off-screen from
// anywhere» expectation. // anywhere» expectation.
lastDelta = Math.min(0, delta); lastDelta = Math.min(0, delta);
atCommit = -lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD; atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
break; break;
case 'form-close': case 'form-close':
// Form close: finger moves UP (delta < 0). Track 1:1, capped // Form close: finger moves UP (delta < 0). Track 1:1, capped
@ -375,42 +353,28 @@ export function useCurtainHandleGesture({
switch (transition) { switch (transition) {
case 'closed-free': case 'closed-free':
// Direction-aware commit from the free-range drag. Pin // Direction-aware commit from the free-range drag. Pin
// wins over peek if both somehow qualified (sign-exclusive // wins over refresh if both somehow qualified (sign-exclusive
// in practice — lastDelta can't be simultaneously <0 and // in practice — lastDelta can't be simultaneously <0 and
// >0). Below either threshold, spring back to closed. // >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 if (lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) { } else if (lastDelta >= REFRESH_COMMIT_PX) {
commitRef.current('peek'); commitRef.current('refresh');
} else { } else {
setLiveDrag(0, false); setLiveDrag(0, false);
} }
break; break;
case 'pinned-free': case 'pinned-free':
// Two-tier commit: peek wins if the finger crossed the // Unpin if the finger crossed the unpin threshold; else snap
// absolute peek planka (matches the visual point peek // back to pinned.
// commits at from closed-free — PIN_TRAVEL_PX to get to if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
// 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 * peekTravelPxRef.current) {
setPinnedRef.current(false);
commitRef.current('peek');
} else if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(false); setPinnedRef.current(false);
} else { } else {
setLiveDrag(0, false); setLiveDrag(0, false);
} }
break; break;
case 'close-peek': case 'close-refresh':
if (-lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) { if (-lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX) {
commitRef.current('closed'); commitRef.current('closed');
} else { } else {
setLiveDrag(0, false); setLiveDrag(0, false);

View file

@ -1,24 +1,24 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { curtainPinnedByTabAtom } from '../../state/mobilePagerHeader'; import { curtainPinnedByTabAtom } from '../../state/mobilePagerHeader';
import { CURTAIN_SNAP_MS, peekTravelPx, SEARCH_FORM_BASE_PX, TABS_ROW_PX } from './geometry'; import { CURTAIN_SNAP_MS, MASCOT_CARD_PX, SEARCH_FORM_BASE_PX, TABS_ROW_PX } from './geometry';
// Discrete snap stops for the curtain. The curtain's resting `top` // Discrete snap stops for the curtain. The curtain's resting `top`
// is derived from this value plus the live finger drag delta. There // is derived from this value plus the live finger drag delta. There
// is no separate «active vs peek» mode flag — the snap encodes both. // is no separate «active vs reveal» mode flag — the snap encodes both.
export type CurtainSnap = export type CurtainSnap =
| 'closed' // curtain flush under tabs row; nothing peeking | 'closed' // curtain flush under tabs row; nothing revealed
| 'peek' // action chip(s) revealed (Search, plus the tab's create action on Channels) | 'refresh' // dancing-mascot pull-to-refresh card revealed (momentary)
| 'form-search'; // full search form revealed | 'form-search'; // full search form revealed
export const isFormSnap = (snap: CurtainSnap): snap is 'form-search' => snap === 'form-search'; export const isFormSnap = (snap: CurtainSnap): snap is 'form-search' => snap === 'form-search';
export const isPeekSnap = (snap: CurtainSnap): snap is 'peek' => snap === 'peek'; export const isRefreshSnap = (snap: CurtainSnap): snap is 'refresh' => snap === 'refresh';
// Whether a form is currently rendered in the header. Stays set during // Whether a form is currently rendered in the header. Stays set during
// the curtain's close transition so the form has content to slide // the curtain's close transition so the form has content to slide
// behind; cleared by `acknowledgeClosed` after the snap settles at // behind; cleared by `acknowledgeClosed` after the snap settles at
// `closed`. There is only one form now (search) — new-chat was folded // `closed`. There is only one form (search) — new-chat was folded
// into search's People section — so this is a simple on/off flag. // into search's People section — so this is a simple on/off flag.
export type ActiveForm = 'search' | null; export type ActiveForm = 'search' | null;
@ -37,8 +37,8 @@ export type CurtainState = {
activeForm: ActiveForm; activeForm: ActiveForm;
// Live finger delta in px. Added to the snap-derived resting top to // Live finger delta in px. Added to the snap-derived resting top to
// compute the curtain's visible top. Stays at 0 when no gesture is // compute the curtain's visible top. Stays at 0 when no gesture is
// in flight. Positive = finger pulled down (peek reveal); negative = // in flight. Positive = finger pulled down (refresh reveal); negative
// finger pulled up (close gesture). // = finger pulled up (close gesture).
liveDragPx: number; liveDragPx: number;
// True while a touch gesture is in flight. Controls the curtain's // True while a touch gesture is in flight. Controls the curtain's
// `top` transition: disabled while dragging (curtain tracks finger // `top` transition: disabled while dragging (curtain tracks finger
@ -52,10 +52,6 @@ export type CurtainState = {
// watches this to feed `formHeightPx`. Consumer attaches it to the // watches this to feed `formHeightPx`. Consumer attaches it to the
// form's wrapping element. // form's wrapping element.
formMeasureRef: React.RefObject<HTMLDivElement>; formMeasureRef: React.RefObject<HTMLDivElement>;
// Closed→peek travel for this curtain's chip count. Threaded into the
// gesture hooks (commit scale) and `snapTopPx` (rest position) so the
// two stay in lockstep.
peekTravelPx: number;
// Open the search form. Sets `snap` and `activeForm` synchronously. // Open the search form. Sets `snap` and `activeForm` synchronously.
open: () => void; open: () => void;
// Close the curtain (raise it back to `closed`). Keeps `activeForm` // Close the curtain (raise it back to `closed`). Keeps `activeForm`
@ -65,9 +61,9 @@ export type CurtainState = {
// Commit a snap stop directly. Used by the touch gesture on release. // Commit a snap stop directly. Used by the touch gesture on release.
// Also resets `liveDragPx` and `isDragging` in one batched update. // Also resets `liveDragPx` and `isDragging` in one batched update.
// Narrowed to the two non-form destinations the gesture hooks ever // Narrowed to the two non-form destinations the gesture hooks ever
// reach — peek-reveal and close. Form snaps are entered through // reach — refresh-reveal and close. Form snaps are entered through
// `open()` which sets `activeForm` synchronously alongside the snap. // `open()` which sets `activeForm` synchronously alongside the snap.
commit: (next: 'peek' | 'closed') => void; commit: (next: 'refresh' | 'closed') => void;
// Setter for the live drag delta — called from the touch gesture on // Setter for the live drag delta — called from the touch gesture on
// every touchmove. Updates are batched by React inside event handlers. // every touchmove. Updates are batched by React inside event handlers.
setLiveDrag: (px: number, dragging: boolean) => void; setLiveDrag: (px: number, dragging: boolean) => void;
@ -77,29 +73,26 @@ export type CurtainState = {
acknowledgeClosed: () => void; acknowledgeClosed: () => void;
}; };
// Resting `top` (px) of the curtain for a given snap stop, the // Resting `top` (px) of the curtain for a given snap stop and the
// currently measured form height (null falls back to the base), and // currently measured form height (null falls back to the base).
// the chip-count-derived peek travel (`peekTravel`). The peek rest
// lands exactly `peekTravel` below the tabs row so the curtain's edge
// sits on the boundary below the revealed chip(s) — no «next chip pill
// peeking through».
// //
// The form rests at exactly `TABS_ROW_PX + formHeight` — NO breather // The refresh snap rests exactly `MASCOT_CARD_PX` below the tabs row so
// (unlike peek). The search results are a scroll list, and any light- // the dancing-mascot card is fully revealed below the boundary.
// blue strip between the last visible result and the curtain's top //
// reads as «content cut off early» rather than as breathing room. So // The form rests at exactly `TABS_ROW_PX + formHeight` — the search
// the form's measured height already reaches the curtain top: the list // results are a scroll list, and any strip between the last visible
// clips flush at the curtain's edge. `SEARCH_FORM_BASE_PX` is the // result and the curtain's top reads as «content cut off early» rather
// pre-measure fallback and is sized (= the form's full outer height) // than as breathing room. So the form's measured height already reaches
// so it equals the measured `formH`, avoiding any open-time top jump. // the curtain top: the list clips flush at the curtain's edge.
// The chip/peek breather lives separately in `peekTravel` and is // `SEARCH_FORM_BASE_PX` is the pre-measure fallback and is sized (= the
// untouched. // form's full outer height) so it equals the measured `formH`, avoiding
export function snapTopPx(snap: CurtainSnap, formH: number | null, peekTravel: number): number { // any open-time top jump.
export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
switch (snap) { switch (snap) {
case 'closed': case 'closed':
return TABS_ROW_PX; return TABS_ROW_PX;
case 'peek': case 'refresh':
return TABS_ROW_PX + peekTravel; return TABS_ROW_PX + MASCOT_CARD_PX;
case 'form-search': case 'form-search':
return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX); return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX);
default: default:
@ -107,11 +100,7 @@ export function snapTopPx(snap: CurtainSnap, formH: number | null, peekTravel: n
} }
} }
// `chipRows` = how many peek chips this curtain reveals (1 on Direct = export function useCurtainState(pinKey: string): CurtainState {
// Search only; 2 on Channels = Search + the contextual create action).
// Drives the peek travel so the rest position and gesture commit scale
// match the actual chip stack.
export function useCurtainState(pinKey: string, chipRows: number): CurtainState {
const [snap, setSnap] = useState<CurtainSnap>('closed'); const [snap, setSnap] = useState<CurtainSnap>('closed');
const [activeForm, setActiveForm] = useState<ActiveForm>(null); const [activeForm, setActiveForm] = useState<ActiveForm>(null);
const [formHeightPx, setFormHeightPx] = useState<number | null>(null); const [formHeightPx, setFormHeightPx] = useState<number | null>(null);
@ -124,8 +113,6 @@ export function useCurtainState(pinKey: string, chipRows: number): CurtainState
const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom); const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom);
const pinned = !!pinnedMap[pinKey]; const pinned = !!pinnedMap[pinKey];
const peekTravel = peekTravelPx(chipRows);
const formMeasureRef = useRef<HTMLDivElement>(null); const formMeasureRef = useRef<HTMLDivElement>(null);
const setPinned = useCallback( const setPinned = useCallback(
@ -151,8 +138,8 @@ export function useCurtainState(pinKey: string, chipRows: number): CurtainState
setLiveDragPx(0); setLiveDragPx(0);
setIsDragging(false); setIsDragging(false);
// Safety net: clear pin so the form is visible. In practice the // Safety net: clear pin so the form is visible. In practice the
// visible openers (static pager header icons, in-pane chips on // visible opener (the tabs-row Search icon, via the per-pane header
// non-pager surfaces) are all covered by the curtain when pinned, // or the static pager header) is covered by the curtain when pinned,
// so the user can't trigger this directly — but a future // so the user can't trigger this directly — but a future
// programmatic open() would otherwise mount the form behind the // programmatic open() would otherwise mount the form behind the
// still-pinned curtain at curtainTop=0 and present an invisible // still-pinned curtain at curtainTop=0 and present an invisible
@ -166,14 +153,21 @@ export function useCurtainState(pinKey: string, chipRows: number): CurtainState
setIsDragging(false); setIsDragging(false);
}, []); }, []);
const commit = useCallback((next: 'peek' | 'closed') => { const commit = useCallback((next: 'refresh' | 'closed') => {
setSnap(next); setSnap(next);
setLiveDragPx(0); setLiveDragPx(0);
setIsDragging(false); setIsDragging(false);
// `activeForm` is intentionally NOT cleared here — it stays set // A 'refresh' commit reveals the mascot, so drop any lingering form.
// so the closing transition has form content beneath the curtain // Without this, a fast close→pull-to-refresh landed inside the form's
// as it slides up. `acknowledgeClosed` clears it once the snap // ~280480ms close-slide window (before `acknowledgeClosed`/the safety
// settles at `closed`. // timer clear `activeForm`) would leave `activeForm` set, and the
// header's `activeForm ? form : mascot` gate would render the closing
// search form in place of the mascot during the dance.
//
// The 'closed' destination intentionally KEEPS `activeForm` set so the
// closing transition has form content beneath the curtain as it slides
// up — `acknowledgeClosed` clears it once the snap settles at `closed`.
if (next === 'refresh') setActiveForm(null);
}, []); }, []);
const setLiveDrag = useCallback((px: number, dragging: boolean) => { const setLiveDrag = useCallback((px: number, dragging: boolean) => {
@ -233,7 +227,6 @@ export function useCurtainState(pinKey: string, chipRows: number): CurtainState
isDragging, isDragging,
formHeightPx, formHeightPx,
formMeasureRef, formMeasureRef,
peekTravelPx: peekTravel,
open, open,
close, close,
commit, commit,
@ -248,7 +241,6 @@ export function useCurtainState(pinKey: string, chipRows: number): CurtainState
liveDragPx, liveDragPx,
isDragging, isDragging,
formHeightPx, formHeightPx,
peekTravel,
open, open,
close, close,
commit, commit,

View file

@ -569,8 +569,8 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
// the inline override). This contains the container's // the inline override). This contains the container's
// `VOJO_HORSESHOE_VOID_COLOR` paint to the carve cut-out at // `VOJO_HORSESHOE_VOID_COLOR` paint to the carve cut-out at
// the bottom of the appBody. Otherwise the void would bleed // the bottom of the appBody. Otherwise the void would bleed
// up through every transparent pixel of the chip-area band // up through every transparent pixel of the mascot/form band
// between the static header and the curtain top (peek/form // between the static header and the curtain top (refresh/form
// snaps), turning the strip black. // snaps), turning the strip black.
// //
// The static-header z-elevation in `MobileTabsPagerHeader` // The static-header z-elevation in `MobileTabsPagerHeader`

View file

@ -3,7 +3,8 @@ import { IconSrc } from 'folds';
// Per-tab «primary action» published by a pane's StreamHeader. When // Per-tab «primary action» published by a pane's StreamHeader. When
// present it replaces the default Plus / «new chat» button in the // present it replaces the default Plus / «new chat» button in the
// static header (and the matching peek-chip in the per-pane StreamHeader). // static header (and renders the matching Plus in the per-pane
// StreamHeader's tabs row).
// Channels uses this to surface «create channel» (inside a workspace) // Channels uses this to surface «create channel» (inside a workspace)
// or «create community» (on the landing) instead of the DM-creation // or «create community» (on the landing) instead of the DM-creation
// path that Direct keeps as default. // path that Direct keeps as default.