diff --git a/public/locales/en.json b/public/locales/en.json index 9515c380..f7127904 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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", diff --git a/public/locales/ru.json b/public/locales/ru.json index 42a3d815..6bded616 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -448,6 +448,7 @@ "segment_dm": "Личные", "segment_channels": "Каналы", "segment_bots": "Роботы", + "refreshing": "Обновляем…", "self_row_label": "Я", "self_row_preview": "Настройки и профиль", "message_me_label": "я", diff --git a/public/res/img/mascot-dance.png b/public/res/img/mascot-dance.png new file mode 100644 index 00000000..f377a232 Binary files /dev/null and b/public/res/img/mascot-dance.png differ diff --git a/public/res/img/mascot-dance.webm b/public/res/img/mascot-dance.webm new file mode 100644 index 00000000..1d0bcaca Binary files /dev/null and b/public/res/img/mascot-dance.webm differ diff --git a/src/app/components/mobile-tabs-pager/style.css.ts b/src/app/components/mobile-tabs-pager/style.css.ts index 533cb282..e944a297 100644 --- a/src/app/components/mobile-tabs-pager/style.css.ts +++ b/src/app/components/mobile-tabs-pager/style.css.ts @@ -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. diff --git a/src/app/components/stream-header/Chip.tsx b/src/app/components/stream-header/Chip.tsx deleted file mode 100644 index 417e4783..00000000 --- a/src/app/components/stream-header/Chip.tsx +++ /dev/null @@ -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 ( - - - {label} - - ); -} diff --git a/src/app/components/stream-header/RefreshMascot.tsx b/src/app/components/stream-header/RefreshMascot.tsx new file mode 100644 index 00000000..e14f4c2b --- /dev/null +++ b/src/app/components/stream-header/RefreshMascot.tsx @@ -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 ; 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 `` — 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 ( + + {/* 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 ? ( + + ) : ( + + )} + + {caption} + + + ); +} diff --git a/src/app/components/stream-header/StreamHeader.css.ts b/src/app/components/stream-header/StreamHeader.css.ts index 4bb23626..13d75e6c 100644 --- a/src/app/components/stream-header/StreamHeader.css.ts +++ b/src/app/components/stream-header/StreamHeader.css.ts @@ -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`, }); diff --git a/src/app/components/stream-header/StreamHeader.tsx b/src/app/components/stream-header/StreamHeader.tsx index d14097d6..0c210129 100644 --- a/src/app/components/stream-header/StreamHeader.tsx +++ b/src/app/components/stream-header/StreamHeader.tsx @@ -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({ )} - {/* ── 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 ? ( ) : ( - <> - - - - {/* 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 && ( - - - - )} - > + // 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. + )} + {/* ── 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 && ( + + {[0, 0.4, 0.8, 1.2, 1.6, 2.0, 2.4, 2.8, 3.2, 3.6].map((delay) => ( + + ))} + + )} + {/* ── 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. diff --git a/src/app/components/stream-header/forms/InlineRoomSearch.tsx b/src/app/components/stream-header/forms/InlineRoomSearch.tsx index 80f9e928..1333fd65 100644 --- a/src/app/components/stream-header/forms/InlineRoomSearch.tsx +++ b/src/app/components/stream-header/forms/InlineRoomSearch.tsx @@ -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 diff --git a/src/app/components/stream-header/geometry.ts b/src/app/components/stream-header/geometry.ts index 68264f64..c3dc1adb 100644 --- a/src/app/components/stream-header/geometry.ts +++ b/src/app/components/stream-header/geometry.ts @@ -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. diff --git a/src/app/components/stream-header/index.ts b/src/app/components/stream-header/index.ts index 42aeed15..fdf530ea 100644 --- a/src/app/components/stream-header/index.ts +++ b/src/app/components/stream-header/index.ts @@ -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'; diff --git a/src/app/components/stream-header/useCurtainBodyGesture.ts b/src/app/components/stream-header/useCurtainBodyGesture.ts index ea91aa93..1f19ec78 100644 --- a/src/app/components/stream-header/useCurtainBodyGesture.ts +++ b/src/app/components/stream-header/useCurtainBodyGesture.ts @@ -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(pinned); pinnedRef.current = pinned; - const peekTravelPxRef = useRef(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); diff --git a/src/app/components/stream-header/useCurtainHandleGesture.ts b/src/app/components/stream-header/useCurtainHandleGesture.ts index ece87a34..c29c4993 100644 --- a/src/app/components/stream-header/useCurtainHandleGesture.ts +++ b/src/app/components/stream-header/useCurtainHandleGesture.ts @@ -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; // 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(pinned); pinnedRef.current = pinned; - const peekTravelPxRef = useRef(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); diff --git a/src/app/components/stream-header/useCurtainState.ts b/src/app/components/stream-header/useCurtainState.ts index 6216f15c..a9f7b286 100644 --- a/src/app/components/stream-header/useCurtainState.ts +++ b/src/app/components/stream-header/useCurtainState.ts @@ -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; - // 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('closed'); const [activeForm, setActiveForm] = useState(null); const [formHeightPx, setFormHeightPx] = useState(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(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 + // ~280–480ms 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, diff --git a/src/app/features/settings/MobileSettingsHorseshoe.tsx b/src/app/features/settings/MobileSettingsHorseshoe.tsx index c5a654fe..128f38f3 100644 --- a/src/app/features/settings/MobileSettingsHorseshoe.tsx +++ b/src/app/features/settings/MobileSettingsHorseshoe.tsx @@ -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` diff --git a/src/app/state/mobilePagerHeader.ts b/src/app/state/mobilePagerHeader.ts index ff431ff1..02161e0d 100644 --- a/src/app/state/mobilePagerHeader.ts +++ b/src/app/state/mobilePagerHeader.ts @@ -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.