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_channels": "Channels",
"segment_bots": "Robots",
"refreshing": "Refreshing…",
"self_row_label": "You",
"self_row_preview": "Settings & profile",
"message_me_label": "me",

View file

@ -448,6 +448,7 @@
"segment_dm": "Личные",
"segment_channels": "Каналы",
"segment_bots": "Роботы",
"refreshing": "Обновляем…",
"self_row_label": "Я",
"self_row_preview": "Настройки и профиль",
"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.
//
// `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
// listener calls preventDefault, and only after axis-resolve commits
// to "horizontal".
@ -93,7 +93,7 @@ export const pagerRoot = style({
// top in the safe-top + tabsRow band — the void is contained to
// the carve area, tabs stay visible. The horseshoe's `appBody`
// 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
// in the same state (see `StreamHeader.gestureDisabled`) so no
// 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 { color, config, toRem } from 'folds';
import {
CHIP_GAP_PX,
CURTAIN_BREATHER_PX,
CURTAIN_RADIUS_PX,
CURTAIN_SNAP_EASING,
CURTAIN_SNAP_MS,
HANDLE_HEIGHT_PX,
MASCOT_CARD_PX,
MASCOT_VIDEO_PX,
TABS_ROW_PX,
WEB_TABS_ROW_PX,
} from './geometry';
@ -36,15 +36,16 @@ export const stage = style({
},
});
// Header — always-rendered strip carrying tabs row + (optional) chip
// reveal area + (optional) active form. The curtain slides on top of
// the area BELOW the tabs row to cover/reveal those children.
// Header — always-rendered strip carrying tabs row + (optional)
// mascot-refresh card + (optional) active form. The curtain slides on
// 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
// `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
// composed of folds-styled inputs with their own backgrounds, so the
// peek/form snaps stay visually opaque without this layer.
// curtain isn't. The mascot card carries its own `SurfaceVariant`
// backdrop and the inline form is composed of folds-styled inputs with
// their own backgrounds, so the refresh/form snaps stay visually opaque
// without this layer.
export const header = style({
position: 'absolute',
top: 0,
@ -133,7 +134,7 @@ export const iconsCluster = style({
// rendered position — disabled during drag, restored on commit.
//
// 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.
// Drop ONLY the «card» rounding (top corners flat). The divider rule
// at the seam is owned by `tabsRow.borderBottom` under the same
@ -158,8 +159,7 @@ export const curtain = style({
borderTopLeftRadius: toRem(CURTAIN_RADIUS_PX),
borderTopRightRadius: toRem(CURTAIN_RADIUS_PX),
transition: `top ${CURTAIN_SNAP_MS}ms ${CURTAIN_SNAP_EASING}`,
// Hint the compositor while the curtain is moving. Cheap since the
// curtain is the only element in this stacking context that animates.
// Hint the compositor while the curtain is moving.
willChange: 'top',
selectors: {
'[data-platform="web"] &': {
@ -295,62 +295,93 @@ export const segmentDot = recipe({
},
});
// Chip row — outer clip-strip. Both rows reveal together when the
// user drags the curtain down to the `peek` snap.
//
// The `marginBottom` math is load-bearing for the snap-top
// calculation: the resting `top` of `peek` lands the curtain exactly
// where the next row would have begun, so the breather never "steals"
// pixels from the next chip's paddingTop. Two different values:
// - default (chip-to-chip): `CHIP_GAP_PX` — tighter, so the two
// 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',
// Dancing-mascot pull-to-refresh card. Revealed below the tabs row when
// the user drags the curtain down to the `refresh` snap. Its fixed
// height equals `MASCOT_CARD_PX`, which the curtain's refresh rest-top
// (`TABS_ROW_PX + MASCOT_CARD_PX`) lands exactly on — so the card edge
// sits at the curtain's rounded top with the content centred above it.
export const mascotCard = style({
flexShrink: 0,
height: toRem(MASCOT_CARD_PX),
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: toRem(10),
width: '100%',
height: toRem(48),
padding: `${toRem(8)} ${toRem(14)}`,
borderRadius: toRem(20),
font: 'inherit',
fontSize: toRem(14),
textAlign: 'left',
cursor: 'pointer',
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
WebkitAppearance: 'none',
justifyContent: 'center',
gap: toRem(2),
// Own opaque backdrop so the refresh reveal stays solid in pager mode,
// where the `header` bg collapses to transparent (the transparent
// mascot video would otherwise show the static pager header through it).
backgroundColor: color.SurfaceVariant.Container,
});
export const chipPlaceholder = style({
opacity: 0.65,
// Radar-pulse overlay. Spans the visible light-blue surface behind the
// 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',
});
// Active form area in the header. Outer is `position: relative`; the
// inner mounted form fills it with `top: 0` so the form's first
// element (input bar) sits flush at the line where chips would
// otherwise live.
// inner mounted form fills it with `top: 0` so the form's input bar
// sits flush just below the tabs row.
export const formArea = style({
position: 'relative',
flexShrink: 0,
@ -362,14 +393,12 @@ export const formInner = style({
top: 0,
left: 0,
right: 0,
// Narrower side padding than the chip rows (24px) so the search form
// uses more of the column width — the inset bar read as «narrow».
// 12px side padding insets the search bar a little from the column edge.
//
// 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
// top edge. Any bottom padding here — like the curtain breather the
// form snap deliberately drops (see `snapTopPx`) — would reintroduce a
// light-blue strip that clips the search results before they reach the
// curtain. The 8px top padding stays: it's the gap below the tabs row.
// top edge. Any bottom padding here would reintroduce a light-blue strip
// that clips the search results before they reach the curtain. The 8px
// top padding stays: it's the gap below the tabs row.
padding: `${toRem(8)} ${toRem(12)} 0`,
});

View file

@ -11,9 +11,11 @@ import React, {
import { useTranslation } from 'react-i18next';
import { useMatch, useNavigate } from 'react-router-dom';
import { useAtomValue, useSetAtom } from 'jotai';
import { ClientEvent, SyncState } from 'matrix-js-sdk';
import { Box, Icon, IconButton, Icons, toRem } from 'folds';
import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths';
import { isNativePlatform } from '../../utils/capacitor';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useBotPresets } from '../../features/bots/catalog';
import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext';
import {
@ -21,13 +23,13 @@ import {
StreamHeaderPrimaryAction,
mobilePagerCurtainAtom,
} 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 { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
import * as css from './StreamHeader.css';
import { Segment } from './Segment';
import { Chip } from './Chip';
import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState';
import { RefreshMascot } from './RefreshMascot';
import { isFormSnap, isRefreshSnap, snapTopPx, useCurtainState } from './useCurtainState';
import { useCurtainHandleGesture } from './useCurtainHandleGesture';
import { useCurtainBodyGesture } from './useCurtainBodyGesture';
import { InlineRoomSearch } from './forms/InlineRoomSearch';
@ -62,8 +64,8 @@ type StreamHeaderProps = {
// listing share `"channels"` so pin survives the toggle between
// empty state and a chosen workspace.
pinKey: string;
// Optional contextual create action (a Plus button + peek chip). When
// omitted (Direct) the header shows no Plus at all — starting a chat
// Optional contextual create action (a Plus button in the tabs row).
// When omitted (Direct) the header shows no Plus at all — starting a chat
// is folded into search, which finds people via the homeserver user
// directory. Channels sets this to «create channel» / «create
// community» so its Plus launches that flow.
@ -120,12 +122,8 @@ export function StreamHeader({
else navigate(BOTS_PATH, navOpts);
}, [selectTabInstant, navigate, navOpts]);
// Peek reveals a single Search chip by default (Direct — new-chat is
// gone, people are found through search), or two when a `primaryAction`
// 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);
const curtain = useCurtainState(pinKey);
const mx = useMatrixClient();
// Suppress every curtain gesture whenever the user is interacting
// with something else that would otherwise race the pin path:
@ -153,8 +151,8 @@ export function StreamHeader({
// * `useCurtainHandleGesture` — the dedicated 32 px drag-handle
// at the top of the curtain. Crisp 1:1 finger ↔ curtain. From
// closed the gesture is a free-range drag spanning pin↔closed↔
// peek in one motion (`closed-free`); other snaps drive single-
// destination transitions (unpin / close-peek / form-close).
// refresh in one motion (`closed-free`); other snaps drive single-
// destination transitions (unpin / close-refresh / form-close).
// Engages regardless of whether the chat list is scrollable —
// the handle is a distinct surface and never competes with list
// scroll. Only rendered on native (`isNativePlatform()`).
@ -190,7 +188,6 @@ export function StreamHeader({
snap: curtain.snap,
pinned: curtain.pinned,
setPinned: curtain.setPinned,
peekTravelPx: curtain.peekTravelPx,
setLiveDrag: curtain.setLiveDrag,
commit: curtain.commit,
disabled: gestureDisabled,
@ -203,7 +200,6 @@ export function StreamHeader({
scrollRef,
snap: curtain.snap,
pinned: curtain.pinned,
peekTravelPx: curtain.peekTravelPx,
setLiveDrag: curtain.setLiveDrag,
commit: curtain.commit,
disabled: gestureDisabled,
@ -214,6 +210,97 @@ export function StreamHeader({
const openSearch = useCallback(() => curtain.open(), [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
// compare-and-clear) is meaningful — without useMemo a fresh object
// 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
// 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
// stage (= y = safe-top in viewport), covering the tabs row. The
// 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
// row on web is `WEB_TABS_ROW_PX` (= 54px, matching PageHeader on
// 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
// the smaller tabs row without touching the snap-state machine.
// 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 curtainTop = curtain.pinned
? 0 + curtain.liveDragPx
: snapTopPx(curtain.snap, curtain.formHeightPx, curtain.peekTravelPx) +
platformOffset +
curtain.liveDragPx;
: snapTopPx(curtain.snap, curtain.formHeightPx) + platformOffset + curtain.liveDragPx;
// After the curtain settles at `closed`, unmount any lingering form.
// Guarded so unrelated transitionend events (e.g. children's own
@ -435,19 +520,14 @@ export function StreamHeader({
)}
</div>
{/* Chips vs form
Mutually exclusive. While a form is mounted (including the
curtain's close-snap window before `acknowledgeClosed`), the
chips stay unrendered so the form doesn't visually jump
from y = TABS_ROW_PX to y = TABS_ROW_PX + 2·CHIP_ROW_PX
mid-animation.
When chips are rendered: always present in their fixed
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). */}
{/* Mascot-refresh card vs search form
Mutually exclusive. While the search form is mounted
(including the curtain's close-snap window before
`acknowledgeClosed`), the mascot card stays unrendered so the
form doesn't visually jump mid-animation. Otherwise the mascot
card sits in its fixed header position below the tabs row and
the curtain occludes it by z-stacking; dragging the curtain
down to `refresh` reveals it from underneath. */}
{curtain.activeForm ? (
<div
id={INLINE_FORM_ID}
@ -463,32 +543,33 @@ export function StreamHeader({
</div>
</div>
) : (
<>
<div className={css.chipRow}>
<Chip
iconSrc={Icons.Search}
label={t('Search.search')}
onClick={openSearch}
hidden={curtain.snap !== 'peek'}
/>
</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>
)}
</>
// Dancing-mascot pull-to-refresh card. Rendered in the header
// below the tabs row; the curtain z-occludes it at `closed` and
// the drag-down reveals it at the `refresh` snap. The Search /
// Channels-create chips that used to live here are gone — search
// is reached via the tabs-row icon, Channels-create via its
// tabs-row Plus.
<RefreshMascot
caption={t('Direct.refreshing')}
revealing={isRefreshSnap(curtain.snap) || curtain.isDragging}
/>
)}
</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
Renders ABOVE the header (z-index higher). `top` combines the
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.
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
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.
style={{ width: '100%', height: toRem(SEARCH_FORM_BASE_PX - 8) }}
>
{/* ── Input bar (matches chip geometry: h=48 / r=20 / pad 8/14)
so the chip input morph reads as a content crossfade.
{/* ── Input bar (h=48 / r=20 / pad 8/14 pill).
`width: 100%` + `minWidth: 0` keep the bar a constant full
width whether empty or typed without it the flex row
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
// (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
// closed it sits flush under the tabs row (covering chips + form area
// beneath). Dragging it DOWN reveals more of the header from underneath.
// Dragging UP raises the curtain back over the header.
// closed it sits flush under the tabs row (covering the mascot card +
// form area beneath). Dragging it DOWN reveals more of the header from
// underneath. Dragging UP raises the curtain back over the header.
//
// Snap stops (curtain.top, px):
// pinned = 0 (curtain sits flush at top of the stage, tabs row
@ -14,8 +14,9 @@
// stage stays painted by the surrounding context —
// see «pinned visual contract» below)
// closed = TABS_ROW_PX
// peek = TABS_ROW_PX + 2·CHIP_ROW_PX + CHIP_GAP_PX
// + CURTAIN_BREATHER_PX
// refresh = TABS_ROW_PX + MASCOT_CARD_PX (dancing-mascot pull-to-
// 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
// results clip flush at the curtain's top edge; the
// 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
// → `WEB_TABS_ROW_PX`, plus the divider as `borderBottom`).
// 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`.
export const WEB_TABS_ROW_PX = 54;
// Each peek-chip row. Reveals one chip's pill (h=48) + 8px top breather.
export const CHIP_ROW_PX = 56;
// Vertical gap BETWEEN two consecutive chip rows. Separate from
// `CURTAIN_BREATHER_PX` so the inter-chip spacing can read tighter
// than the breather between the last chip and the curtain's rounded
// top (the curtain's straight edge against a chip pill needs more
// air to avoid feeling «clamped», while two pills sitting in a
// vertical stack want to read as a pair).
export const CHIP_GAP_PX = 14;
// Dancing-mascot pull-to-refresh card. The curtain's `refresh` snap
// rests `MASCOT_CARD_PX` below the tabs row, revealing the card: the
// mascot above a short caption («Обновляем…»), stacked in a centred
// column.
//
// The mascot fits ENTIRELY inside the card (no clipping). The dance clip
// fills ~88% of its 384² frame (the radar rings reach near the edges, so
// there's little transparent margin to crop), so the `MASCOT_VIDEO_PX`
// square video box is kept SMALLER than the card and rendered
// `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
// height is measured at runtime via ResizeObserver and adapts to the
// available viewport so the form never overflows the chats card.
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 —
// emphasized-decelerate territory.
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.
export const CURTAIN_RADIUS_PX = 24;
// Total vertical travel of the curtain between `closed` and `peek` —
// the resting-top delta between the two snaps. Used as the basis for
// the peek-commit threshold: the user must drag (rubber-banded) at
// least COMMIT_THRESHOLD × peekTravel before release for the snap to
// flip. Anything shorter reads as accidental and springs back.
//
// The chip count is DYNAMIC per tab: Direct reveals a single chip
// (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;
// Curtain displacement (px) the user must drag DOWN from closed before
// release for the refresh to commit. Absolute (NOT a fraction of the
// card height) so the pull-to-refresh trigger distance stays a
// comfortable thumb-pull regardless of how tall the mascot card is.
// On the rubber-banded body surface (×RUBBER_BAND) the finger pull
// needed to reach this displacement is correspondingly longer.
export const REFRESH_COMMIT_PX = 88;
// Back-compat default (two-chip peek). The live value is computed per
// StreamHeader via `peekTravelPx(chipRows)`.
export const PEEK_TRAVEL_PX = peekTravelPx(2);
// Refresh dance timing. The mascot stays revealed for AT LEAST
// `REFRESH_MIN_MS` so a near-instant /sync (healthy online client) reads
// 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
// the chip reveal feels resistive; COMMIT_THRESHOLD is the fraction of
// the full peek travel the user must cross on release for the snap to
// commit. Tuned high (≈90%) so anything below «дотянул почти до конца»
// reads as accidental and snaps back to `closed`.
// Touch gesture tuning. RUBBER_BAND dampens finger→curtain motion on
// the body surface so the refresh pull feels resistive (physically
// «heavier» than the handle's crisp 1:1). DIRECTION_DEAD_ZONE_PX is
// the finger travel before a drag resolves to up/down.
// 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 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;
// 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.
//
// 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);
// the committing curtain DISPLACEMENT is still
// `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` ≈ 61 px — essentially «drag
// the curtain across the full tabs-row height». On the body the same
// displacement is reached with a longer finger pull because the body
// path is rubber-banded (×0.65).
// the committing curtain DISPLACEMENT is `PIN_COMMIT_THRESHOLD ×
// PIN_TRAVEL_PX` ≈ 61 px — essentially «drag the curtain across the
// full tabs-row height». On the body the same displacement is reached
// with a longer finger pull because the body path is rubber-banded
// (×0.65).
//
// Unpin's clamp is asymmetric — `pinned-free` lower-bounds the live
// delta at 0 (no destination above pinned) but leaves the upper
// direction unclamped so the same gesture can carry the curtain
// through closed into peek territory in one motion. The handle-only
// contract on unpin means the body never resolves to `pinned-free`,
// so the no-upper-clamp tolerance only applies on the dedicated
// drag-handle.
// `pinned-free` (handle-only) lower-bounds the live delta at 0 (no
// destination above pinned) and commits unpin at this threshold. The
// body never resolves to `pinned-free` — unpin stays a deliberate
// drag-handle pull.
export const PIN_COMMIT_THRESHOLD = 0.95;
// 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.
//
// 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
// inside the curtain is scrollable. See `useCurtainHandleGesture`
// for the full state machine.

View file

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

View file

@ -2,23 +2,23 @@ import { MutableRefObject, useEffect, useRef } from 'react';
import { isNativePlatform } from '../../utils/capacitor';
import {
ACTIVE_CLOSE_THRESHOLD_PX,
COMMIT_THRESHOLD,
DIRECTION_DEAD_ZONE_PX,
PIN_COMMIT_THRESHOLD,
PIN_TRAVEL_PX,
REFRESH_COMMIT_PX,
} from './geometry';
import { CurtainSnap, isFormSnap } from './useCurtainState';
import { CurtainSnap, isFormSnap, isRefreshSnap } from './useCurtainState';
type Args = {
// Drag-handle element at the top of the curtain. ALL curtain
// gestures bind here — the chat list's scroll viewport is left to
// 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.
handleRef: MutableRefObject<HTMLDivElement | null>;
// Current snap stop. Mirrored into a ref so the listener (bound
// 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.
snap: CurtainSnap;
// 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
// drag past the commit threshold qualifies the gesture.
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
// `liveDragPx` from the parent state too, so React drives the
// curtain's `top` re-render — no direct DOM writes.
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
// two non-form destinations the hook ever reaches. Also resets
// 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
// bottom sheet is open or when this pane is inactive inside the
// swipe pager.
@ -60,28 +56,24 @@ type Args = {
// and `useCurtainBodyGesture` for the rubber-banded equivalents.
//
// `closed-free` is the single free-range transition that spans the
// full pin↔closed↔peek vertical range in one gesture. From the closed
// snap, neither direction is locked at the dead-zone: the user can
// drag up past the safe-top zone OR down through the chip area in
// one motion, and the release decides pin / peek / snap-back based
// full pin↔closed↔refresh vertical range in one gesture. From the
// closed snap, neither direction is locked at the dead-zone: the user
// can drag up past the safe-top zone OR down into the refresh card in
// one motion, and the release decides pin / refresh / snap-back based
// on the final position. The earlier pair of one-shot `pin` and
// `peek` transitions used a hard «gate» at the start point (each
// `refresh` transitions used a hard «gate» at the start point (each
// direction was clamped to one side of 0 once the dead-zone resolved
// the direction) and the user reported this as a regression — drag
// up, then back down, ran into an invisible wall at the closed
// position before peek could engage.
// position before the down reveal could engage.
//
// `pinned-free` is the symmetric free-range transition for the
// pinned overlay: from pinned + drag DOWN the curtain follows the
// finger all the way through closed into peek territory in one
// motion. On release, peek wins if the finger crossed the absolute
// peek planka (PIN_TRAVEL_PX + COMMIT_THRESHOLD × PEEK_TRAVEL_PX —
// the same visual point peek commits at from closed-free), unpin
// wins if at least the unpin threshold was reached, otherwise snap
// back to pinned. UP is no-op (no destination above pinned). Only
// the handle resolves to `pinned-free` — the body gesture bails at
// touchstart while pinned so unpin remains a deliberate handle pull.
export type CurtainTransition = 'closed-free' | 'pinned-free' | 'close-peek' | 'form-close';
// `pinned-free` is the free-range DOWN transition for the pinned
// overlay: from pinned + drag DOWN the curtain follows the finger and
// commits unpin (back to closed) past the threshold. 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-refresh' | 'form-close';
// Exhaustive-check helper. Used in the `default` branch of every
// 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
// on commit — no destination above pinned).
// * pinned + DOWN → pinned-free (HANDLE-only contract — the body
// hook bails entirely while pinned so unpin /
// peek-from-pinned stays a deliberate handle
// pull. See
// hook bails entirely while pinned so unpin
// stays a deliberate handle pull. See
// `useCurtainBodyGesture::onTouchStart`).
// * 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
// horizontal-bail check).
// * peek + UP → close-peek (retreat to closed).
// * peek + DOWN → no-op (nothing lower to reveal).
// * refresh + UP → close-refresh (retreat to closed).
// * refresh + DOWN → no-op (nothing lower to reveal).
// * form-* + UP → form-close.
// * form-* + DOWN → no-op (form is already the lowest snap).
export function resolveCurtainTransition(
@ -120,7 +111,7 @@ export function resolveCurtainTransition(
): CurtainTransition | null {
if (pinned) return direction === 'down' ? 'pinned-free' : null;
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;
return null;
}
@ -130,7 +121,7 @@ export function resolveCurtainTransition(
// desktop.
//
// 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
// the chat list inside the curtain is scrollable. The curtain BODY
// 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
// anywhere» on empty / short lists but a real list-scroll is never
// hijacked under their finger. The body is also fully inert while
// pinned, so unpin (and unpin → peek overshoot) stays a deliberate
// handle pull.
// pinned, so unpin stays a deliberate handle pull.
//
// Design rationale: gestures used to bind to the chat list's scroll
// 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
// scrollable lists) removes the scroll/gesture race entirely.
//
@ -154,25 +144,21 @@ export function resolveCurtainTransition(
// * closed-free — NO clamps either side. Finger goes off-
// screen up → curtain follows past safe-top;
// finger crosses back below the start point →
// curtain continues into peek territory in
// curtain continues into the refresh card in
// the same gesture. Direction-aware commit
// on release: pin if pulled UP past
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, peek
// if pulled DOWN past COMMIT_THRESHOLD ×
// PEEK_TRAVEL_PX, else snap back to closed.
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, refresh
// if pulled DOWN past REFRESH_COMMIT_PX, else
// snap back to closed.
// * pinned-free — DOWN-only free-range drag from pinned.
// Clamped at 0 below (no destination above
// pinned), NO upper clamp — the finger can
// carry the curtain through closed into
// peek territory in one motion. Release
// decides peek (lastDelta ≥ PIN_TRAVEL_PX +
// COMMIT_THRESHOLD × PEEK_TRAVEL_PX), unpin
// (lastDelta ≥ PIN_COMMIT_THRESHOLD ×
// PIN_TRAVEL_PX), or snap back to pinned.
// * close-peek — capped at 0 below (no transition lower
// than peek), NO upper clamp (drag past
// pinned). Release commits unpin past
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, else
// snaps back to pinned.
// * close-refresh — capped at 0 below (no transition lower than
// the refresh card), NO upper clamp (drag past
// 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
// push the curtain below its form-snap top,
// NO upper clamp. Commit at
@ -190,7 +176,6 @@ export function useCurtainHandleGesture({
snap,
pinned,
setPinned,
peekTravelPx,
setLiveDrag,
commit,
disabled,
@ -200,8 +185,6 @@ export function useCurtainHandleGesture({
snapRef.current = snap;
const pinnedRef = useRef<boolean>(pinned);
pinnedRef.current = pinned;
const peekTravelPxRef = useRef<number>(peekTravelPx);
peekTravelPxRef.current = peekTravelPx;
const setPinnedRef = useRef(setPinned);
setPinnedRef.current = setPinned;
const commitRef = useRef(commit);
@ -276,8 +259,8 @@ export function useCurtainHandleGesture({
direction = delta > 0 ? 'down' : 'up';
transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction);
// (snap, pinned, direction) has no valid motion — pinned+up,
// peek+down, form+down. Bail so the gesture can be re-armed on
// the next touch sequence; no preventDefault is fired so the
// refresh+down, form+down. Bail so the gesture can be re-armed
// on the next touch sequence; no preventDefault is fired so the
// browser keeps any default behaviour (it would be a no-op
// here anyway — the handle has touchAction:none in CSS).
if (transition === null) {
@ -297,42 +280,37 @@ export function useCurtainHandleGesture({
let atCommit = false;
switch (transition) {
case 'closed-free':
// Single free-range drag spanning pin↔closed↔peek. 1:1 with
// NO clamps either side: the curtain follows the finger off-
// screen upward (past safe-top) and continuously into peek
// territory downward in the same gesture. The release decides
// pin / peek / snap-back from the final lastDelta.
// Single free-range drag spanning pin↔closed↔refresh. 1:1
// with NO clamps either side: the curtain follows the finger
// off-screen upward (past safe-top) and continuously into the
// refresh card downward in the same gesture. The release
// decides pin / refresh / snap-back from the final lastDelta.
lastDelta = delta;
// Direction-aware atCommit so the grabber pill stretches
// whichever way the user is committing. Pin and peek are
// whichever way the user is committing. Pin and refresh are
// sign-exclusive (one branch can't fire simultaneously with
// the other) so a simple ternary on `lastDelta` suffices.
atCommit =
lastDelta <= 0
? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD
: lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD;
: lastDelta >= REFRESH_COMMIT_PX;
break;
case 'pinned-free':
// 1:1 down from pinned. Clamped at 0 below (a downward
// jitter past the start mustn't push the curtain into
// safe-top — there's no destination above pinned), NO
// upper clamp — the curtain follows the finger through
// closed into peek territory in one motion.
// safe-top — there's no destination above pinned). Release
// commits unpin past the threshold.
lastDelta = Math.max(0, delta);
// atCommit fires as soon as ANY commit qualifies (the
// grabber pill stretches to signal «release works here»);
// it stays true past the unpin threshold all the way
// through peek, since both are valid landing zones.
atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
break;
case 'close-peek':
case 'close-refresh':
// 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
// safe-top direction, matching the «drag up off-screen from
// anywhere» expectation.
lastDelta = Math.min(0, delta);
atCommit = -lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD;
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
break;
case 'form-close':
// Form close: finger moves UP (delta < 0). Track 1:1, capped
@ -375,42 +353,28 @@ export function useCurtainHandleGesture({
switch (transition) {
case 'closed-free':
// 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
// >0). Below either threshold, spring back to closed.
if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(true);
} else if (lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) {
commitRef.current('peek');
} else if (lastDelta >= REFRESH_COMMIT_PX) {
commitRef.current('refresh');
} else {
setLiveDrag(0, false);
}
break;
case 'pinned-free':
// Two-tier commit: peek wins if the finger crossed the
// absolute peek planka (matches the visual point peek
// commits at from closed-free — PIN_TRAVEL_PX to get to
// closed + COMMIT_THRESHOLD × PEEK_TRAVEL_PX through the
// chip area); otherwise unpin if at least the unpin
// threshold was reached; else snap back to pinned.
//
// The peek branch MUST clear `pinned` before committing
// the snap. The curtain's resting top is
// `pinned ? 0 : snapTopPx(snap)` — so commit('peek')
// alone would set snap='peek' yet leave the curtain
// visually at top=0 (the pin overlay wins). Both updates
// batch into one render inside this touchend handler.
if (lastDelta >= PIN_TRAVEL_PX + COMMIT_THRESHOLD * peekTravelPxRef.current) {
setPinnedRef.current(false);
commitRef.current('peek');
} else if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
// Unpin if the finger crossed the unpin threshold; else snap
// back to pinned.
if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(false);
} else {
setLiveDrag(0, false);
}
break;
case 'close-peek':
if (-lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) {
case 'close-refresh':
if (-lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX) {
commitRef.current('closed');
} else {
setLiveDrag(0, false);

View file

@ -1,24 +1,24 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useAtom } from 'jotai';
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`
// 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 =
| 'closed' // curtain flush under tabs row; nothing peeking
| 'peek' // action chip(s) revealed (Search, plus the tab's create action on Channels)
| 'closed' // curtain flush under tabs row; nothing revealed
| 'refresh' // dancing-mascot pull-to-refresh card revealed (momentary)
| 'form-search'; // full search form revealed
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
// the curtain's close transition so the form has content to slide
// 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.
export type ActiveForm = 'search' | null;
@ -37,8 +37,8 @@ export type CurtainState = {
activeForm: ActiveForm;
// 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
// in flight. Positive = finger pulled down (peek reveal); negative =
// finger pulled up (close gesture).
// in flight. Positive = finger pulled down (refresh reveal); negative
// = finger pulled up (close gesture).
liveDragPx: number;
// True while a touch gesture is in flight. Controls the curtain's
// `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
// form's wrapping element.
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: () => void;
// 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.
// Also resets `liveDragPx` and `isDragging` in one batched update.
// 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.
commit: (next: 'peek' | 'closed') => void;
commit: (next: 'refresh' | 'closed') => void;
// Setter for the live drag delta — called from the touch gesture on
// every touchmove. Updates are batched by React inside event handlers.
setLiveDrag: (px: number, dragging: boolean) => void;
@ -77,29 +73,26 @@ export type CurtainState = {
acknowledgeClosed: () => void;
};
// Resting `top` (px) of the curtain for a given snap stop, the
// currently measured form height (null falls back to the base), and
// 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».
// Resting `top` (px) of the curtain for a given snap stop and the
// currently measured form height (null falls back to the base).
//
// The form rests at exactly `TABS_ROW_PX + formHeight` — NO breather
// (unlike peek). The search results are a scroll list, and any light-
// 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's measured height already reaches the curtain top: the list
// clips flush at the curtain's edge. `SEARCH_FORM_BASE_PX` is the
// pre-measure fallback and is sized (= the form's full outer height)
// so it equals the measured `formH`, avoiding any open-time top jump.
// The chip/peek breather lives separately in `peekTravel` and is
// untouched.
export function snapTopPx(snap: CurtainSnap, formH: number | null, peekTravel: number): number {
// The refresh snap rests exactly `MASCOT_CARD_PX` below the tabs row so
// the dancing-mascot card is fully revealed below the boundary.
//
// The form rests at exactly `TABS_ROW_PX + formHeight` — the search
// results are a scroll list, and any 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's measured height already reaches
// the curtain top: the list clips flush at the curtain's edge.
// `SEARCH_FORM_BASE_PX` is the pre-measure fallback and is sized (= the
// form's full outer height) so it equals the measured `formH`, avoiding
// any open-time top jump.
export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
switch (snap) {
case 'closed':
return TABS_ROW_PX;
case 'peek':
return TABS_ROW_PX + peekTravel;
case 'refresh':
return TABS_ROW_PX + MASCOT_CARD_PX;
case 'form-search':
return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX);
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 =
// 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 {
export function useCurtainState(pinKey: string): CurtainState {
const [snap, setSnap] = useState<CurtainSnap>('closed');
const [activeForm, setActiveForm] = useState<ActiveForm>(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 pinned = !!pinnedMap[pinKey];
const peekTravel = peekTravelPx(chipRows);
const formMeasureRef = useRef<HTMLDivElement>(null);
const setPinned = useCallback(
@ -151,8 +138,8 @@ export function useCurtainState(pinKey: string, chipRows: number): CurtainState
setLiveDragPx(0);
setIsDragging(false);
// Safety net: clear pin so the form is visible. In practice the
// visible openers (static pager header icons, in-pane chips on
// non-pager surfaces) are all covered by the curtain when pinned,
// visible opener (the tabs-row Search icon, via the per-pane header
// or the static pager header) is covered by the curtain when pinned,
// so the user can't trigger this directly — but a future
// programmatic open() would otherwise mount the form behind the
// still-pinned curtain at curtainTop=0 and present an invisible
@ -166,14 +153,21 @@ export function useCurtainState(pinKey: string, chipRows: number): CurtainState
setIsDragging(false);
}, []);
const commit = useCallback((next: 'peek' | 'closed') => {
const commit = useCallback((next: 'refresh' | 'closed') => {
setSnap(next);
setLiveDragPx(0);
setIsDragging(false);
// `activeForm` is intentionally NOT cleared here — it stays set
// so the closing transition has form content beneath the curtain
// as it slides up. `acknowledgeClosed` clears it once the snap
// settles at `closed`.
// A 'refresh' commit reveals the mascot, so drop any lingering form.
// Without this, a fast close→pull-to-refresh landed inside the form's
// ~280480ms close-slide window (before `acknowledgeClosed`/the safety
// 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) => {
@ -233,7 +227,6 @@ export function useCurtainState(pinKey: string, chipRows: number): CurtainState
isDragging,
formHeightPx,
formMeasureRef,
peekTravelPx: peekTravel,
open,
close,
commit,
@ -248,7 +241,6 @@ export function useCurtainState(pinKey: string, chipRows: number): CurtainState
liveDragPx,
isDragging,
formHeightPx,
peekTravel,
open,
close,
commit,

View file

@ -569,8 +569,8 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
// the inline override). This contains the container's
// `VOJO_HORSESHOE_VOID_COLOR` paint to the carve cut-out at
// the bottom of the appBody. Otherwise the void would bleed
// up through every transparent pixel of the chip-area band
// between the static header and the curtain top (peek/form
// up through every transparent pixel of the mascot/form band
// between the static header and the curtain top (refresh/form
// snaps), turning the strip black.
//
// 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
// 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)
// or «create community» (on the landing) instead of the DM-creation
// path that Direct keeps as default.