feat(stream-header): pin chats curtain over static pager header on drag-up with per-tab atom, native-only rubber-band gesture and pinned-aware horseshoe sheet coordination

This commit is contained in:
heaven 2026-05-19 11:50:31 +03:00
parent 4a9d5f6384
commit 0422a9832f
16 changed files with 618 additions and 79 deletions

View file

@ -357,7 +357,16 @@ export function MobileTabsPager() {
onSelectChannels={onSelectChannels} onSelectChannels={onSelectChannels}
onSelectBots={onSelectBots} onSelectBots={onSelectBots}
/> />
<div className={css.strip} style={stripStyle}> {/* `data-pager-pane="true"` flags everything inside the strip so
per-pane background paints (PageNav-inner surface,
MobileSettings / ChannelsWorkspace appBody, StreamHeader
stage + header) collapse to transparent in pager mode. With
both the strip and the static header at z-auto in pagerRoot,
DOM order puts the strip on top and with the strip's bg
layers transparent, the static header tabs show through every
pixel the curtain isn't covering. See `pagerStaticHeader` in
style.css.ts for the full overlay contract. */}
<div className={css.strip} style={stripStyle} data-pager-pane="true">
<MobilePagerPaneProvider value={directPaneInfo}> <MobilePagerPaneProvider value={directPaneInfo}>
<PaneSlot isActive={directPaneInfo.isActive}> <PaneSlot isActive={directPaneInfo.isActive}>
<Direct /> <Direct />

View file

@ -2,11 +2,22 @@ import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { Box, Icon, IconButton, Icons } from 'folds'; import { Box, Icon, IconButton, Icons } from 'folds';
import { mobilePagerCurtainAtom } from '../../state/mobilePagerHeader'; import {
curtainPinnedByTabAtom,
mobileHorseshoeActiveAtom,
mobilePagerCurtainAtom,
} from '../../state/mobilePagerHeader';
import { Segment } from '../stream-header/Segment'; import { Segment } from '../stream-header/Segment';
import * as streamHeaderCss from '../stream-header/StreamHeader.css'; import * as streamHeaderCss from '../stream-header/StreamHeader.css';
import * as css from './style.css'; import * as css from './style.css';
// Positive z-index applied to the static pager header when any
// horseshoe sheet is geometrically active (drag in flight or sheet
// open). Any positive value beats the strip's `z-index: auto`
// stacking context in pagerRoot, so `z: 1` is sufficient; the constant
// just makes the intent explicit at the call site.
const PAGER_HEADER_ELEVATED_Z = 1;
type Tab = 'direct' | 'channels' | 'bots'; type Tab = 'direct' | 'channels' | 'bots';
// Must match the `INLINE_FORM_ID` local constant in // Must match the `INLINE_FORM_ID` local constant in
@ -71,8 +82,53 @@ export function MobileTabsPagerHeader({
const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]); const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]);
const iconsDisabled = curtainControls === null; const iconsDisabled = curtainControls === null;
// The static header does NOT translate to follow the curtain. It
// stays put; the curtain physically rises ABOVE it via z-stack — see
// the «curtain-overlay invariants» comment in style.css.ts on
// pagerStaticHeader for the bg / z-order contract.
//
// Z-elevation while a horseshoe sheet is GEOMETRICALLY active: the
// MobileSettings / ChannelsWorkspace container paints
// `VOJO_HORSESHOE_VOID_COLOR` (= #000 in dark theme) across the
// entire pane to drive the carve cut-out the moment `expandedPx > 0`.
// Without elevation that void bleeds up through the transparent
// strip-stack into the safe-top + tabsRow zone, turning the system-
// tray strip + tabs black. Bumping the static header into a positive
// z-index puts it ABOVE the strip's stacking context (positive z
// beats z:auto stacking contexts per CSS painting order), covering
// the void in its own y-band with SurfaceVariant bg + visible tabs.
//
// The atom tracks the GEOMETRIC signal (`expandedPx > 0`), not the
// sheet-open atoms, so elevation lands on the FIRST frame of drag —
// not 80 px later when the user crosses the commit threshold. The
// horseshoes' appBody flips to opaque in lockstep (same signal),
// containing the void to the bottom carve everywhere below the
// static header.
//
// Pinned-overrides-elevation: when the active pane's curtain is
// pinned the curtain itself contains the void — it covers everything
// from `y = safe-top` downward inside the strip's stacking context,
// and the opaque appBody (also flipped on `horseshoeActive`) covers
// the safe-top band above the curtain. Re-elevating the static
// header in that state would visibly «slice» the pinned curtain in
// the safe-top + tabsRow band, popping tabs back over what the user
// explicitly pulled up to cover. So we suppress elevation whenever
// the active tab's pin is set — preserves the «pinned hides tabs»
// invariant across sheet open/drag.
//
// The curtain pin gesture is suppressed while either sheet is open
// (see `StreamHeader.gestureDisabled`), so this elevation never
// races with a pin-in-progress drag.
const horseshoeActive = useAtomValue(mobileHorseshoeActiveAtom);
const pinnedByTab = useAtomValue(curtainPinnedByTabAtom);
const activePinned = !!pinnedByTab[activeTab];
const elevated = horseshoeActive && !activePinned;
return ( return (
<div className={css.pagerStaticHeader}> <div
className={css.pagerStaticHeader}
style={elevated ? { zIndex: PAGER_HEADER_ELEVATED_Z } : undefined}
>
<div className={streamHeaderCss.tabsRow}> <div className={streamHeaderCss.tabsRow}>
<div className={streamHeaderCss.tabsCluster}> <div className={streamHeaderCss.tabsCluster}>
<Segment <Segment

View file

@ -39,23 +39,81 @@ export const pagerRoot = style({
color: color.Background.OnContainer, color: color.Background.OnContainer,
}); });
// Shared static tabs row painted ABOVE the strip. Reserves the // Shared static tabs row painted BEHIND the strip in DOM order.
// status-bar safe-area inset via padding-top so the segments + icons // Reserves the status-bar safe-area inset via padding-top so the
// sit just below the system status bar, and so the backdrop colour // segments + icons sit just below the system status bar, and so the
// extends through the inset zone (matching the per-pane PageNav's // backdrop colour extends through the inset zone (matching the per-pane
// own `paddingTop: var(--vojo-safe-top)` so there's no visible band // PageNav's own `paddingTop: var(--vojo-safe-top)` so there's no
// boundary at the inset edge). // visible band boundary at the inset edge).
// //
// `z-index: 10` keeps this above the strip and any in-pane curtain // Curtain-overlay invariants (why no z-index here at rest):
// (curtain is `z-index: 2` within its pane's stacking context, which //
// in turn lives inside the strip with z-index auto = 0 in pagerRoot's // The chats curtain must visually rise ABOVE this header when the
// context — so 10 reliably wins). // user pulls it up to the «pinned» snap — like a real blind sliding
// over the segments rather than the segments moving. The curtain
// lives inside each pane > stage with `z: 2` in stage's local
// stacking context. The stage / pane stack inside the swipe `strip`,
// which creates its own stacking context via `transform`. To let the
// curtain visually surface above this static header we:
//
// (a) leave both elements at `z-index: auto` in pagerRoot's
// stacking context (this block has no `zIndex` AT REST), so
// painting order falls back to DOM order. `pagerStaticHeader`
// is rendered BEFORE `strip` in MobileTabsPager — so the
// strip (and everything inside it that paints opaquely) paints
// on top.
//
// (b) tag the strip with `data-pager-pane="true"`. All per-pane
// background paints (PageNav-inner surface, MobileSettings/
// ChannelsWorkspace appBody, StreamHeader stage + header)
// become transparent under that selector, so the static
// header tabs show through every transparent layer of the
// strip until the curtain — the only remaining opaque element
// — covers them by being positioned at top: 0 (pinned snap).
//
// Breaking (a) or (b) re-introduces the «paravozik» regression where
// the tabs visually slide with the curtain. See git history for the
// user-feedback trail.
//
// Conditional z-elevation (horseshoe-active override, suppressed by pin):
//
// When a horseshoe sheet (Settings or workspace switcher) is
// geometrically active — i.e. `expandedPx > 0`, which covers both
// the in-flight drag and the committed-open state — the wrapping
// container paints `VOJO_HORSESHOE_VOID_COLOR` (= #000 in dark
// theme) across the entire pane so the carve at the sheet's top
// reads as a dark seam. With the transparent strip stack from (b),
// that void would bleed up through the safe-top + tabsRow zone,
// turning the system-tray strip + tabs solid black.
//
// `MobileTabsPagerHeader.tsx` bumps this element to a positive
// `zIndex` (inline style, driven by `mobileHorseshoeActiveAtom`)
// from the first frame of drag. Positive z beats the strip's
// `z: auto` stacking context, putting the static header back on
// 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
// 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.
//
// Pinned-override: when the active pane's curtain is pinned, the
// curtain itself sits at the top of the stage (z:2 inside the
// strip's stacking ctx) and covers everything from `y = safe-top`
// downward — including the tabsRow band. Above the curtain
// (y=0..safe-top) the opaque appBody contains the void. Elevating
// the static header in that state would visibly slice the pinned
// curtain in the tabsRow band, popping tabs over what the user
// explicitly pulled up to cover. So `MobileTabsPagerHeader`
// suppresses elevation whenever `curtainPinnedByTabAtom[activeTab]`
// is true — preserves the «pinned hides tabs» invariant across
// sheet open/drag without re-introducing the void leak.
export const pagerStaticHeader = style({ export const pagerStaticHeader = style({
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
zIndex: 10,
paddingTop: 'var(--vojo-safe-top, 0px)', paddingTop: 'var(--vojo-safe-top, 0px)',
// The wrapped tabsRow has its own height of TABS_ROW_PX via the // The wrapped tabsRow has its own height of TABS_ROW_PX via the
// stream-header recipe; we don't set a fixed height here so the // stream-header recipe; we don't set a fixed height here so the

View file

@ -12,6 +12,14 @@ import {
// Stage. Position-relative anchor. The header itself paints the // Stage. Position-relative anchor. The header itself paints the
// light-blue backdrop; the curtain is layered ABOVE it via z-index. // light-blue backdrop; the curtain is layered ABOVE it via z-index.
//
// In pager mode the bg collapses to transparent so the pager's static
// header (sitting behind the strip in DOM order) shows through every
// pixel the curtain isn't covering. See
// `mobile-tabs-pager/style.css.ts::pagerStaticHeader` for the full
// curtain-overlay contract. The strip is tagged
// `data-pager-pane="true"` in MobileTabsPager.tsx, which gates this
// selector.
export const stage = style({ export const stage = style({
position: 'relative', position: 'relative',
flex: 1, flex: 1,
@ -19,11 +27,22 @@ export const stage = style({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
backgroundColor: color.SurfaceVariant.Container, backgroundColor: color.SurfaceVariant.Container,
selectors: {
'[data-pager-pane="true"] &': {
backgroundColor: 'transparent',
},
},
}); });
// Header — always-rendered strip carrying tabs row + (optional) chip // Header — always-rendered strip carrying tabs row + (optional) chip
// reveal area + (optional) active form. The curtain slides on top of // reveal area + (optional) active form. The curtain slides on top of
// the area BELOW the tabs row to cover/reveal those children. // 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.
export const header = style({ export const header = style({
position: 'absolute', position: 'absolute',
top: 0, top: 0,
@ -35,6 +54,11 @@ export const header = style({
// everything below the tabs row when raised. // everything below the tabs row when raised.
zIndex: 1, zIndex: 1,
backgroundColor: color.SurfaceVariant.Container, backgroundColor: color.SurfaceVariant.Container,
selectors: {
'[data-pager-pane="true"] &': {
backgroundColor: 'transparent',
},
},
}); });
// Tabs row. Stays fully visible regardless of curtain position // Tabs row. Stays fully visible regardless of curtain position

View file

@ -9,15 +9,16 @@ import React, {
} from 'react'; } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMatch, useNavigate } from 'react-router-dom'; import { useMatch, useNavigate } from 'react-router-dom';
import { useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { Box, Icon, IconButton, Icons, toRem } from 'folds'; import { Box, Icon, IconButton, Icons, toRem } from 'folds';
import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths'; import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths';
import { isNativePlatform } from '../../utils/capacitor'; import { isNativePlatform } from '../../utils/capacitor';
import { useBotPresets } from '../../features/bots/catalog'; import { useBotPresets } from '../../features/bots/catalog';
import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext'; import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext';
import { MobilePagerCurtainControls, mobilePagerCurtainAtom } from '../../state/mobilePagerHeader'; import { MobilePagerCurtainControls, mobilePagerCurtainAtom } from '../../state/mobilePagerHeader';
import { settingsSheetAtom } from '../../state/settingsSheet';
import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
import * as css from './StreamHeader.css'; import * as css from './StreamHeader.css';
import { CHIP_ROW_PX, TABS_ROW_PX } from './geometry';
import { Segment } from './Segment'; import { Segment } from './Segment';
import { Chip } from './Chip'; import { Chip } from './Chip';
import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState'; import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState';
@ -42,9 +43,17 @@ type StreamHeaderProps = {
// so the on-screen keyboard's viewport resize doesn't push them up // so the on-screen keyboard's viewport resize doesn't push them up
// over the form (see commit 14ed080). // over the form (see commit 14ed080).
bottomPinned?: ReactNode; bottomPinned?: ReactNode;
// Stable identifier used to persist the curtain's pinned overlay
// across listing-pane remounts (the user taps into a Room and back,
// which unmounts the listing pane). When provided, pin state is
// stored in `curtainPinnedByTabAtom[pinKey]`; without it, pin lives
// in a local useState that resets on unmount. Listing surfaces
// wired into the mobile pager (Direct / Channels / Bots) all pass
// a key; other consumers can omit it.
pinKey?: string;
}; };
export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeaderProps) { export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: StreamHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const bots = useBotPresets(); const bots = useBotPresets();
@ -66,13 +75,37 @@ export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeader
const inPagerMode = pagerPane !== null; const inPagerMode = pagerPane !== null;
const isActivePagerPane = pagerPane?.isActive ?? false; const isActivePagerPane = pagerPane?.isActive ?? false;
const curtain = useCurtainState(); const curtain = useCurtainState(pinKey);
// Suppress the curtain gesture whenever the user is interacting with
// something else that would otherwise race the pin path:
//
// * Settings sheet open (DirectSelfRow-originated bottom sheet) —
// a drag-up on the still-visible list above the sheet would
// mutate the pin atom underneath the sheet and the user would
// see an unexpected pinned curtain on dismissal.
// * Workspace switcher sheet open — same shape, on the Channels
// workspace surface.
// * Inactive pager pane — the strip clips offscreen panes so they
// shouldn't receive touches in practice, but bind defense-in-
// depth so a stray pointer event on a translateX'd pane never
// pins someone else's tab.
//
// Mirrors `MobileTabsPager.gestureDisabled` which suppresses the
// pager's OWN horizontal-swipe gesture under the same conditions.
const settingsSheetOpen = !!useAtomValue(settingsSheetAtom);
const workspaceSheetOpen = !!useAtomValue(channelsWorkspaceSheetAtom);
const offscreenPagerPane = inPagerMode && !isActivePagerPane;
const gestureDisabled = settingsSheetOpen || workspaceSheetOpen || offscreenPagerPane;
useCurtainGesture({ useCurtainGesture({
scrollRef, scrollRef,
snap: curtain.snap, snap: curtain.snap,
pinned: curtain.pinned,
setPinned: curtain.setPinned,
setLiveDrag: curtain.setLiveDrag, setLiveDrag: curtain.setLiveDrag,
commit: curtain.commit, commit: curtain.commit,
disabled: gestureDisabled,
}); });
const isActive = isFormSnap(curtain.snap); const isActive = isFormSnap(curtain.snap);
@ -111,7 +144,15 @@ export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeader
// delta. React-driven (no inline DOM writes), so finger-tracking and // delta. React-driven (no inline DOM writes), so finger-tracking and
// commit happen in the same render pipeline and there's no // commit happen in the same render pipeline and there's no
// intermediate "snap back, then animate" flash on release. // intermediate "snap back, then animate" flash on release.
const curtainTop = snapTopPx(curtain.snap, curtain.formHeightPx) + curtain.liveDragPx; //
// When `pinned` is true the local snap (kept at {closed, peek,
// 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
// swiping between Direct / Channels / Bots preserves the lock.
const curtainTop = curtain.pinned
? 0 + curtain.liveDragPx
: snapTopPx(curtain.snap, curtain.formHeightPx) + curtain.liveDragPx;
// After the curtain settles at `closed`, unmount any lingering form. // After the curtain settles at `closed`, unmount any lingering form.
// Guarded so unrelated transitionend events (e.g. children's own // Guarded so unrelated transitionend events (e.g. children's own
@ -347,5 +388,3 @@ export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeader
</div> </div>
); );
} }
export { TABS_ROW_PX, CHIP_ROW_PX };

View file

@ -9,10 +9,26 @@
// Dragging UP raises the curtain back over the header. // Dragging UP raises the curtain back over the header.
// //
// Snap stops (curtain.top, px): // Snap stops (curtain.top, px):
// pinned = 0 (curtain sits flush at top of the stage, tabs row
// covered; the safe-top status-bar strip above the
// stage stays painted by the surrounding context —
// see «pinned visual contract» below)
// closed = TABS_ROW_PX // closed = TABS_ROW_PX
// peek = TABS_ROW_PX + 2·CHIP_ROW_PX + CHIP_GAP_PX // peek = TABS_ROW_PX + 2·CHIP_ROW_PX + CHIP_GAP_PX
// + CURTAIN_BREATHER_PX // + CURTAIN_BREATHER_PX
// form:* = TABS_ROW_PX + formHeight + CURTAIN_BREATHER_PX // form:* = TABS_ROW_PX + formHeight + CURTAIN_BREATHER_PX
//
// Pinned visual contract: at `pinned` the curtain's top edge lands at
// y = safe-top in viewport coords (because the stage starts after the
// PageNav / appBody padding-top: var(--vojo-safe-top)). The system tray
// strip stays painted by appBody / PageNav-inner / MobileTabsPager's
// static header — all of which use `SurfaceVariant.Container` for that
// zone, so the colour is continuous across surfaces. The curtain MUST
// NOT extend into the safe-top zone (otherwise system text is covered)
// and MUST NOT add internal padding-top (otherwise the chat list grows
// visually taller). The clamp on the up-drag (= -TABS_ROW_PX) enforces
// the first invariant; we deliberately do not add any padding inside
// the curtain to enforce the second.
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
// Tabs row height. Always visible above the curtain. // Tabs row height. Always visible above the curtain.
@ -69,3 +85,35 @@ export const DIRECTION_DEAD_ZONE_PX = 10;
export const COMMIT_THRESHOLD = 0.9; export const COMMIT_THRESHOLD = 0.9;
// Pull-up distance (raw finger px) required to close an active form. // Pull-up distance (raw finger px) required to close an active form.
export const ACTIVE_CLOSE_THRESHOLD_PX = 100; export const ACTIVE_CLOSE_THRESHOLD_PX = 100;
// Total vertical CURTAIN travel for the closed ↔ pinned gesture.
// Equals the tabs row height because pinning lifts the curtain by
// exactly that distance (from y = TABS_ROW_PX down to y = 0 inside
// the stage).
export const PIN_TRAVEL_PX = TABS_ROW_PX;
// Rubber-band attenuation for the pin / unpin drag. Finger → curtain
// motion is scaled by this factor so the curtain feels heavier than
// the finger — a deliberate gate against accidental drag-up.
//
// User-confirmed intent: «вверх дотянуть нужно явно» — pinning must be
// an obvious sustained pull, not a casual flick. With 0.45 the user
// must drag ~142px of finger to slide the curtain across the full
// PIN_TRAVEL_PX (64px); combined with the very-high commit threshold
// below the minimum committing pull is ~135px, which is well above
// any plausible accidental scroll-attempt at scrollTop=0.
//
// Same factor applies to unpin (drag down from pinned) so both
// directions feel consistent — neither commits on a casual gesture.
export const PIN_RUBBER_BAND = 0.45;
// Commit threshold for pin / unpin. Tuned very high (≈95%) so the
// user must drag the curtain almost-all-the-way to the cap before
// release for the snap to flip. Anything shorter reads as accidental
// and springs back to the previous resting snap.
//
// The geometric meaning: after rubber-band, lastDelta ranges 0 …
// ±PIN_TRAVEL_PX (clamped). The commit fires when |lastDelta| ≥
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, i.e. the curtain visually
// reached at least 95% of the snap distance.
export const PIN_COMMIT_THRESHOLD = 0.95;

View file

@ -5,6 +5,9 @@ import {
COMMIT_THRESHOLD, COMMIT_THRESHOLD,
DIRECTION_DEAD_ZONE_PX, DIRECTION_DEAD_ZONE_PX,
PEEK_TRAVEL_PX, PEEK_TRAVEL_PX,
PIN_COMMIT_THRESHOLD,
PIN_RUBBER_BAND,
PIN_TRAVEL_PX,
RUBBER_BAND, RUBBER_BAND,
} from './geometry'; } from './geometry';
import { CurtainSnap, isFormSnap } from './useCurtainState'; import { CurtainSnap, isFormSnap } from './useCurtainState';
@ -17,6 +20,15 @@ type Args = {
// Current snap stop. Read at touchstart to decide gesture meaning. // Current snap stop. Read at touchstart to decide gesture meaning.
// Mirrored into a ref so the listener (bound once) reads fresh values. // Mirrored into a ref so the listener (bound once) reads fresh values.
snap: CurtainSnap; snap: CurtainSnap;
// Per-pane pinned overlay. When true the curtain renders at the top
// regardless of `snap`, and the gesture interprets a drag-down as
// unpin instead of peek-reveal. Also mirrored into a ref for the
// bound-once listener.
pinned: boolean;
// Setter for the pinned overlay; called on release once the user's
// up-drag (from closed) or down-drag (from pinned) past the commit
// threshold qualifies the gesture.
setPinned: (next: boolean) => void;
// Setter for the live drag delta during touchmove. The hook reads // Setter for the live drag delta during touchmove. The hook reads
// `liveDragPx` from the parent state too, so React drives the // `liveDragPx` from the parent state too, so React drives the
// curtain's `top` re-render — no direct DOM writes, no inline-vs- // curtain's `top` re-render — no direct DOM writes, no inline-vs-
@ -24,25 +36,61 @@ type Args = {
setLiveDrag: (px: number, dragging: boolean) => void; setLiveDrag: (px: number, dragging: boolean) => void;
// Commit a new snap stop on release. // Commit a new snap stop on release.
commit: (next: CurtainSnap) => void; commit: (next: CurtainSnap) => void;
// Suppress gesture binding entirely. Used to gate pinning when a
// bottom sheet (Settings, workspace switcher) is open — otherwise
// a drag-up on the visible list above the sheet would mutate the
// pin atom underneath the sheet and the user would see an unexpected
// pinned curtain on sheet dismissal. Also gated when this pane is
// inactive inside the swipe pager so stray touches on offscreen
// panes can't mutate any tab's pin.
disabled?: boolean;
}; };
// Touch-gesture driver for the curtain. Native-only: on web/PC the // Touch-gesture driver for the curtain. Native-only: on web/PC the
// listeners aren't attached at all. // listeners aren't attached at all.
// //
// Peek path: drag down from `closed` rubber-bands the live delta and // Peek path: drag down from `closed` (not pinned) rubber-bands the live
// on release past the threshold commits to `peek` (both chips // delta and on release past the threshold commits to `peek` (both
// revealed in one motion). Drag UP from `peek` retreats to `closed`. // chips revealed in one motion). Drag UP from `peek` retreats to
// `closed`.
//
// Pin path: drag UP from `closed` (not pinned) tracks the finger 1:1
// with a hard clamp at -PIN_TRAVEL_PX (curtain stops flush above the
// tabs row — never enters the system-tray safe-top zone). On release
// past `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` flips `pinned = true`.
//
// Unpin path: drag DOWN while `pinned = true` tracks 1:1 clamped at
// +PIN_TRAVEL_PX. On release past the same threshold flips
// `pinned = false`. The user-confirmed UX choice is single-stop —
// unpin lands at `closed` only; peek requires a separate gesture.
// //
// Form-close path: drag UP from a form snap tracks the finger 1:1; on // Form-close path: drag UP from a form snap tracks the finger 1:1; on
// release past `ACTIVE_CLOSE_THRESHOLD_PX` commits to `closed`. // release past `ACTIVE_CLOSE_THRESHOLD_PX` commits to `closed`.
export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args): void { export function useCurtainGesture({
scrollRef,
snap,
pinned,
setPinned,
setLiveDrag,
commit,
disabled,
}: Args): void {
// Mirror snap into a ref so the listener — bound once via useEffect — // Mirror snap into a ref so the listener — bound once via useEffect —
// always reads the freshest value without re-attaching. // always reads the freshest value without re-attaching.
const snapRef = useRef<CurtainSnap>(snap); const snapRef = useRef<CurtainSnap>(snap);
snapRef.current = snap; snapRef.current = snap;
// Same mirror trick for the global pinned overlay so the listener
// sees fresh values without re-binding on every render.
const pinnedRef = useRef<boolean>(pinned);
pinnedRef.current = pinned;
// Stable setter ref so the bound-once listener can call the latest
// setPinned without us needing to add it to the effect deps.
const setPinnedRef = useRef(setPinned);
setPinnedRef.current = setPinned;
useEffect(() => { useEffect(() => {
if (!isNativePlatform()) return undefined; if (!isNativePlatform()) return undefined;
if (disabled) return undefined;
const list = scrollRef.current; const list = scrollRef.current;
if (!list) return undefined; if (!list) return undefined;
@ -92,6 +140,7 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
const delta = e.touches[0].clientY - startY; const delta = e.touches[0].clientY - startY;
const deltaX = startX !== null ? e.touches[0].clientX - startX : 0; const deltaX = startX !== null ? e.touches[0].clientX - startX : 0;
const currentSnap = snapRef.current; const currentSnap = snapRef.current;
const currentPinned = pinnedRef.current;
// Resolve a direction once the finger crosses the dead-zone. // Resolve a direction once the finger crosses the dead-zone.
if (direction === null) { if (direction === null) {
@ -110,21 +159,27 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
} }
direction = delta > 0 ? 'down' : 'up'; direction = delta > 0 ? 'down' : 'up';
// Direction guards: nothing higher than `closed`; nothing // Direction guards:
// lower than `peek`; form snaps only close (up). // - pinned ⇒ only DOWN (unpin); UP would try to push past
if (currentSnap === 'closed' && direction === 'up') { // y = -safe-top into the system-tray zone, which we never
// want.
// - peek ⇒ only UP (close); DOWN has nowhere lower to go.
// - form ⇒ only UP (close); DOWN reads as a list scroll.
// - closed + UP ⇒ pin path (new — used to bail).
// - closed + DOWN ⇒ peek path (existing).
if (currentPinned && direction === 'up') {
startX = null; startX = null;
startY = null; startY = null;
direction = null; direction = null;
return; return;
} }
if (currentSnap === 'peek' && direction === 'down') { if (!currentPinned && currentSnap === 'peek' && direction === 'down') {
startX = null; startX = null;
startY = null; startY = null;
direction = null; direction = null;
return; return;
} }
if (isFormSnap(currentSnap) && direction === 'down') { if (!currentPinned && isFormSnap(currentSnap) && direction === 'down') {
startX = null; startX = null;
startY = null; startY = null;
direction = null; direction = null;
@ -135,11 +190,27 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
engaged = true; engaged = true;
e.preventDefault(); e.preventDefault();
if (isFormSnap(currentSnap)) { if (currentPinned) {
// Unpin: finger moves DOWN (delta > 0). Rubber-banded by
// PIN_RUBBER_BAND so the curtain feels heavy — same factor as
// the pin path below for symmetry. Clamped at +PIN_TRAVEL_PX
// so the curtain doesn't visually descend past its `closed`
// resting top during the drag.
lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta * PIN_RUBBER_BAND));
} else if (isFormSnap(currentSnap)) {
// Form close: finger moves UP (delta < 0). Track 1:1, capped // Form close: finger moves UP (delta < 0). Track 1:1, capped
// at 0 so an accidental downward jitter doesn't push the // at 0 so an accidental downward jitter doesn't push the
// curtain below its resting position. // curtain below its resting position.
lastDelta = Math.min(0, delta); lastDelta = Math.min(0, delta);
} else if (currentSnap === 'closed' && direction === 'up') {
// Pin: finger moves UP (delta < 0). Rubber-banded by
// PIN_RUBBER_BAND — the curtain moves at ≈45% of finger speed
// so the user has to commit a deliberate sustained pull to
// close the gap. Clamped at -PIN_TRAVEL_PX so it never enters
// the system-tray safe-top zone. See geometry's «pinned visual
// contract» and `PIN_RUBBER_BAND` rationale for the full
// invariant.
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta * PIN_RUBBER_BAND));
} else { } else {
// Peek: rubber-banded BOTH directions. Down (delta > 0) reveals // Peek: rubber-banded BOTH directions. Down (delta > 0) reveals
// more chips; up (delta < 0) retreats toward `closed`. Bounds // more chips; up (delta < 0) retreats toward `closed`. Bounds
@ -158,12 +229,32 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
} }
const currentSnap = snapRef.current; const currentSnap = snapRef.current;
const currentPinned = pinnedRef.current;
let next: CurtainSnap = currentSnap; let next: CurtainSnap = currentSnap;
let nextPinned = currentPinned;
if (isFormSnap(currentSnap)) { if (currentPinned) {
// Unpin commit: drag DOWN past ≥ PIN_COMMIT_THRESHOLD of the
// full travel flips pinned → false. The user-confirmed UX is
// single-stop — unpin lands at `closed`; peek requires a
// separate gesture.
const progress = lastDelta / PIN_TRAVEL_PX;
if (progress >= PIN_COMMIT_THRESHOLD) {
nextPinned = false;
}
} else if (isFormSnap(currentSnap)) {
if (Math.abs(lastDelta) >= ACTIVE_CLOSE_THRESHOLD_PX) { if (Math.abs(lastDelta) >= ACTIVE_CLOSE_THRESHOLD_PX) {
next = 'closed'; next = 'closed';
} }
} else if (currentSnap === 'closed' && direction === 'up') {
// Pin commit: drag UP past ≥ PIN_COMMIT_THRESHOLD of the full
// travel flips pinned → true. The user emphasised this must
// require «дотянул прям до самого верха» — high threshold
// matches that intent.
const progress = -lastDelta / PIN_TRAVEL_PX;
if (progress >= PIN_COMMIT_THRESHOLD) {
nextPinned = true;
}
} else { } else {
// Single-stage peek toggle. Threshold is COMMIT_THRESHOLD of // Single-stage peek toggle. Threshold is COMMIT_THRESHOLD of
// the FULL peek travel — the rubber-banded drag must reach // the FULL peek travel — the rubber-banded drag must reach
@ -177,7 +268,12 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
} }
} }
if (next !== currentSnap) { if (nextPinned !== currentPinned) {
// setPinned() resets liveDragPx + isDragging in the same
// batched update — React renders the curtain at the new
// pinned-derived top with the snap transition re-enabled.
setPinnedRef.current(nextPinned);
} else if (next !== currentSnap) {
// commit() also resets liveDragPx + isDragging to 0/false in // commit() also resets liveDragPx + isDragging to 0/false in
// one batched update — React renders the curtain at the new // one batched update — React renders the curtain at the new
// resting top with the snap transition re-enabled. // resting top with the snap transition re-enabled.
@ -216,7 +312,10 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
list.removeEventListener('touchcancel', onTouchCancel); list.removeEventListener('touchcancel', onTouchCancel);
}; };
// setLiveDrag/commit are stable useCallbacks; scrollRef is stable. // setLiveDrag/commit are stable useCallbacks; scrollRef is stable.
// `snap` is mirrored via snapRef written above on every render. // `snap`, `pinned` and `setPinned` are mirrored via the refs above
// so the listener (bound once per disabled-flip) reads fresh values
// without re-attaching every render. `disabled` is the only signal
// that needs to tear the listeners down — it goes into the deps.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollRef, setLiveDrag, commit]); }, [scrollRef, setLiveDrag, commit, disabled]);
} }

View file

@ -1,4 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import { curtainPinnedByTabAtom } from '../../state/mobilePagerHeader';
import { import {
CHIP_GAP_PX, CHIP_GAP_PX,
CHIP_ROW_PX, CHIP_ROW_PX,
@ -17,9 +19,7 @@ export type CurtainSnap =
| 'form-search' // full search form revealed | 'form-search' // full search form revealed
| 'form-chat'; // full new-chat form revealed | 'form-chat'; // full new-chat form revealed
export const isFormSnap = ( export const isFormSnap = (snap: CurtainSnap): snap is 'form-search' | 'form-chat' =>
snap: CurtainSnap
): snap is 'form-search' | 'form-chat' =>
snap === 'form-search' || snap === 'form-chat'; snap === 'form-search' || snap === 'form-chat';
export const isPeekSnap = (snap: CurtainSnap): snap is 'peek' => snap === 'peek'; export const isPeekSnap = (snap: CurtainSnap): snap is 'peek' => snap === 'peek';
@ -31,6 +31,18 @@ export type ActiveForm = 'search' | 'chat' | null;
export type CurtainState = { export type CurtainState = {
snap: CurtainSnap; snap: CurtainSnap;
// Per-tab pin overlay. Stored in `curtainPinnedByTabAtom` keyed by
// the consumer-supplied `pinKey` so the lock survives the route-
// driven listing-pane unmount when the user taps into a Room and
// back. Each tab keeps its own pin (Direct/Channels/Bots are
// independent). If no `pinKey` is provided, the pin lives in a
// local useState that resets on unmount — fine for non-listing
// surfaces where pinning isn't expected anyway.
pinned: boolean;
// Setter for the pinned overlay. Called by the gesture hook on
// commit (drag-up-from-closed past threshold sets true; drag-down-
// while-pinned past threshold sets false).
setPinned: (next: boolean) => void;
activeForm: ActiveForm; activeForm: ActiveForm;
// Live finger delta in px. Added to the snap-derived resting top to // Live finger delta in px. Added to the snap-derived resting top to
// compute the curtain's visible top. Stays at 0 when no gesture is // compute the curtain's visible top. Stays at 0 when no gesture is
@ -80,13 +92,7 @@ export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
case 'closed': case 'closed':
return TABS_ROW_PX; return TABS_ROW_PX;
case 'peek': case 'peek':
return ( return TABS_ROW_PX + CHIP_ROW_PX + CHIP_GAP_PX + CHIP_ROW_PX + CURTAIN_BREATHER_PX;
TABS_ROW_PX +
CHIP_ROW_PX +
CHIP_GAP_PX +
CHIP_ROW_PX +
CURTAIN_BREATHER_PX
);
case 'form-search': case 'form-search':
case 'form-chat': case 'form-chat':
return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX) + CURTAIN_BREATHER_PX; return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX) + CURTAIN_BREATHER_PX;
@ -95,21 +101,60 @@ export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
} }
} }
export function useCurtainState(): CurtainState { export function useCurtainState(pinKey?: string): CurtainState {
const [snap, setSnap] = useState<CurtainSnap>('closed'); const [snap, setSnap] = useState<CurtainSnap>('closed');
const [activeForm, setActiveForm] = useState<ActiveForm>(null); const [activeForm, setActiveForm] = useState<ActiveForm>(null);
const [formHeightPx, setFormHeightPx] = useState<number | null>(null); const [formHeightPx, setFormHeightPx] = useState<number | null>(null);
const [liveDragPx, setLiveDragPx] = useState(0); const [liveDragPx, setLiveDragPx] = useState(0);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
// Pin storage split: atom-backed when `pinKey` is supplied (survives
// listing-pane remount on Room navigate-back), local useState
// fallback when no key is supplied (web/non-listing mounts where
// pinning isn't expected).
const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom);
const [pinnedLocal, setPinnedLocal] = useState(false);
const pinned = pinKey ? !!pinnedMap[pinKey] : pinnedLocal;
const formMeasureRef = useRef<HTMLDivElement>(null); const formMeasureRef = useRef<HTMLDivElement>(null);
const open = useCallback((form: 'search' | 'chat') => { const setPinned = useCallback(
setActiveForm(form); (next: boolean) => {
setSnap(form === 'search' ? 'form-search' : 'form-chat'); if (pinKey) {
setLiveDragPx(0); setPinnedMap((prev) => {
setIsDragging(false); // Compare-and-skip so we don't allocate a fresh object (and
}, []); // re-render every other subscriber of the atom) when nothing
// actually changes.
if (!!prev[pinKey] === next) return prev;
return { ...prev, [pinKey]: next };
});
} else {
setPinnedLocal(next);
}
// Drop any in-flight live drag on commit so the curtain renders
// at the new pinned-derived top without a residual finger offset.
setLiveDragPx(0);
setIsDragging(false);
},
[pinKey, setPinnedMap]
);
const open = useCallback(
(form: 'search' | 'chat') => {
setActiveForm(form);
setSnap(form === 'search' ? 'form-search' : 'form-chat');
setLiveDragPx(0);
setIsDragging(false);
// Safety net: clear pin so the form is visible. In practice the
// static pager header's icons (the only call site of open()) are
// covered by the curtain when pinned, so the user can't trigger
// this directly — but a future programmatic open() or a per-pane
// tabsRow that escapes the pager-mode visibility:hidden gate
// would otherwise mount the form behind the still-pinned curtain
// at curtainTop=0 and the user would see an invisible form.
setPinned(false);
},
[setPinned]
);
const close = useCallback(() => { const close = useCallback(() => {
setSnap('closed'); setSnap('closed');
@ -180,6 +225,8 @@ export function useCurtainState(): CurtainState {
return useMemo( return useMemo(
() => ({ () => ({
snap, snap,
pinned,
setPinned,
activeForm, activeForm,
liveDragPx, liveDragPx,
isDragging, isDragging,
@ -193,6 +240,8 @@ export function useCurtainState(): CurtainState {
}), }),
[ [
snap, snap,
pinned,
setPinned,
activeForm, activeForm,
liveDragPx, liveDragPx,
isDragging, isDragging,

View file

@ -19,16 +19,26 @@ export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
// (PageNav's inner column for the Direct route). // (PageNav's inner column for the Direct route).
// //
// `marginTop: -var(--vojo-safe-top)` extends the container UP over the // `marginTop: -var(--vojo-safe-top)` extends the container UP over the
// status-bar safe-top zone reserved by `PageNav` via `padding-top`. With // status-bar safe-top zone reserved by `PageNav` via `padding-top`,
// this offset the wrapped StreamHeader's `curtain` (which positions // and the compensating `paddingTop: var(--vojo-safe-top)` on `appBody`
// `top: <negative>` when dragged past `closed`) can paint into the // keeps the wrapped DM list anchored at the same visual Y as before
// status-bar zone — without it, both `overflow: hidden` here and the // the shift. The combination has two load-bearing effects:
// `clipPath` on `appBody` would clip those pixels at the bottom of //
// the status-bar strip and the strip would stay uncovered, breaking // (1) The settings-sheet clip-path mask on `appBody` carves rounded
// parity with Bots / ChannelsRoot (where StreamHeader is a direct // BL/BR into an opaque surface that already paints THROUGH the
// child of PageNav and no such wrapper clip exists). The compensating // status-bar strip — without the upward extension the carve
// `padding-top` lives on `appBody` so the wrapped DM list / tabs row // would visibly stop at the bottom of the system-tray strip.
// stay visually anchored at the same Y as before the shift. // (2) `appBody`'s bg paints the safe-top strip itself in the same
// `SurfaceVariant.Container` tone as `PageNav-inner` / the
// pager's static header, giving the system-tray text a
// consistent backdrop across surfaces.
//
// Note: the curtain itself NEVER paints into the safe-top zone — the
// pin gesture's hard clamp at `-PIN_TRAVEL_PX = -TABS_ROW_PX` stops
// the curtain at `top: 0` of the stage (= `y = safe-top` in viewport),
// preserving the system-tray strip. See
// `components/stream-header/geometry.ts::PIN_TRAVEL_PX` and the
// «pinned visual contract» comment for the full invariant.
export const container = style({ export const container = style({
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',

View file

@ -48,12 +48,14 @@
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'; import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useAtomValue } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { settingsSheetAtom } from '../../state/settingsSheet'; import { settingsSheetAtom } from '../../state/settingsSheet';
import { useCloseSettingsSheet, useOpenSettingsSheet } from '../../state/hooks/settingsSheet'; import { useCloseSettingsSheet, useOpenSettingsSheet } from '../../state/hooks/settingsSheet';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { HorseshoeEnabledContext } from '../../components/page'; import { HorseshoeEnabledContext } from '../../components/page';
import { useMobilePagerPane } from '../../components/mobile-tabs-pager/MobilePagerPaneContext';
import { mobileHorseshoeActiveAtom } from '../../state/mobilePagerHeader';
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe'; import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
import { Settings } from './Settings'; import { Settings } from './Settings';
import * as css from './MobileSettingsHorseshoe.css'; import * as css from './MobileSettingsHorseshoe.css';
@ -116,6 +118,15 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
const sheet = useAtomValue(settingsSheetAtom); const sheet = useAtomValue(settingsSheetAtom);
const openSheet = useOpenSettingsSheet(); const openSheet = useOpenSettingsSheet();
const closeSheet = useCloseSettingsSheet(); const closeSheet = useCloseSettingsSheet();
// In pager mode the appBody's SurfaceVariant bg would cover the
// pager's static header tabs (which live behind the swipe strip in
// DOM order so the chats curtain can rise OVER them like a real
// blind). Drop the bg in pager mode — the pager root paints the same
// SurfaceVariant tone behind the strip, and the static header owns
// the visible safe-top + tabsRow strip. See
// `mobile-tabs-pager/style.css.ts::pagerStaticHeader` for the
// overlay contract.
const inPagerMode = useMobilePagerPane() !== null;
const [drag, setDrag] = useState<DragState | null>(null); const [drag, setDrag] = useState<DragState | null>(null);
const [viewportHeight, setViewportHeight] = useState(() => const [viewportHeight, setViewportHeight] = useState(() =>
@ -188,6 +199,17 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
const isDragging = drag !== null; const isDragging = drag !== null;
const horseshoeActive = expandedPx > 0; const horseshoeActive = expandedPx > 0;
// Bridge our local `horseshoeActive` (geometric) signal up to the
// pager-shared atom so `MobileTabsPagerHeader` can z-elevate the
// static header from the first frame of drag — see the atom's docs
// for the «no black flash» rationale. The cleanup writing `false`
// covers route unmount mid-drag.
const setMobileHorseshoeActive = useSetAtom(mobileHorseshoeActiveAtom);
useEffect(() => {
setMobileHorseshoeActive(horseshoeActive);
return () => setMobileHorseshoeActive(false);
}, [horseshoeActive, setMobileHorseshoeActive]);
const handleRef = useRef<HTMLDivElement>(null); const handleRef = useRef<HTMLDivElement>(null);
// Refs so the always-installed event handlers see the latest state // Refs so the always-installed event handlers see the latest state
@ -536,6 +558,28 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
clipPath: appBodyClipPath, clipPath: appBodyClipPath,
transition: appBodyTransition, transition: appBodyTransition,
overscrollBehaviorY: 'contain', overscrollBehaviorY: 'contain',
// In pager mode the appBody is transparent AT REST so the
// static pager header (sitting behind the swipe strip in DOM
// order) shows through the tabsRow zone — that's how the
// chats curtain visually rises ABOVE the header on pin.
//
// When the horseshoe is active (drag in flight OR sheet
// open) we flip the appBody back to opaque
// `SurfaceVariant.Container` (via the CSS class — unsetting
// 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
// snaps), turning the strip black.
//
// The static-header z-elevation in `MobileTabsPagerHeader`
// tracks the same `mobileHorseshoeActiveAtom` so the static
// header z-pops above the now-opaque appBody in the safe-
// top + tabsRow band — tabs stay visible. Both gestures
// (pin + pager swipe) are gated off while a sheet is open,
// so the opaque flip can't race with curtain-show-through.
backgroundColor: inPagerMode && !horseshoeActive ? 'transparent' : undefined,
}} }}
> >
{children} {children}

View file

@ -3,6 +3,7 @@ import { Box, color, config, toRem } from 'folds';
import { useMatch } from 'react-router-dom'; import { useMatch } from 'react-router-dom';
import { PageNav, PageNavContent } from '../../../components/page'; import { PageNav, PageNavContent } from '../../../components/page';
import { StreamHeader } from '../../../components/stream-header'; import { StreamHeader } from '../../../components/stream-header';
import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
import { useBotPresets } from '../../../features/bots/catalog'; import { useBotPresets } from '../../../features/bots/catalog';
import type { BotPreset } from '../../../features/bots/catalog'; import type { BotPreset } from '../../../features/bots/catalog';
import { BotCard } from '../../../features/bots/BotCard'; import { BotCard } from '../../../features/bots/BotCard';
@ -24,10 +25,14 @@ export function Bots() {
// only) can recognise list scrollTop=0 and engage the curtain peek. // only) can recognise list scrollTop=0 and engage the curtain peek.
// Icons + click flows work on every platform regardless. // Icons + click flows work on every platform regardless.
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
// Skip PageNav surface in pager mode — see Direct.tsx for the
// rationale; the static header behind the strip owns the visible
// safe-top + tabsRow tone.
const inPagerMode = useMobilePagerPane() !== null;
return ( return (
<PageNav resizable surface="surfaceVariant"> <PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
<StreamHeader scrollRef={scrollRef}> <StreamHeader scrollRef={scrollRef} pinKey="bots">
<PageNavContent scrollRef={scrollRef}> <PageNavContent scrollRef={scrollRef}>
<Box <Box
direction="Column" direction="Column"

View file

@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
import { useSpace } from '../../../hooks/useSpace'; import { useSpace } from '../../../hooks/useSpace';
import { PageNav, PageNavContent } from '../../../components/page'; import { PageNav, PageNavContent } from '../../../components/page';
import { StreamHeader } from '../../../components/stream-header'; import { StreamHeader } from '../../../components/stream-header';
import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
import { ChannelsList } from './ChannelsList'; import { ChannelsList } from './ChannelsList';
import { ChannelCreateRow } from './ChannelCreateRow'; import { ChannelCreateRow } from './ChannelCreateRow';
import { ChannelsLanding } from './ChannelsLanding'; import { ChannelsLanding } from './ChannelsLanding';
@ -25,9 +26,18 @@ import { ACTIVE_SPACE_KEY } from './useActiveSpace';
// the centering to top-aligned. Same idiom as `Direct.tsx::DirectEmpty`. // the centering to top-aligned. Same idiom as `Direct.tsx::DirectEmpty`.
export function ChannelsRootNav() { export function ChannelsRootNav() {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
// Skip PageNav surface in pager mode so the pager's static header
// tabs (sitting behind the swipe strip in DOM order) show through
// until covered by the rising curtain. See Direct.tsx for the same
// pattern and `mobile-tabs-pager/style.css.ts::pagerStaticHeader`
// for the full overlay contract.
const inPagerMode = useMobilePagerPane() !== null;
return ( return (
<PageNav resizable surface="surfaceVariant"> <PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
<StreamHeader scrollRef={scrollRef}> {/* Shared `pinKey` with the workspace-listing `Channels` below
so pin/unpin persists when the user toggles between the
landing CTA and a chosen workspace within the Channels tab. */}
<StreamHeader scrollRef={scrollRef} pinKey="channels">
<ChannelsLanding /> <ChannelsLanding />
</StreamHeader> </StreamHeader>
</PageNav> </PageNav>
@ -46,6 +56,7 @@ export function ChannelsRootNav() {
export function Channels() { export function Channels() {
const space = useSpace(); const space = useSpace();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const inPagerMode = useMobilePagerPane() !== null;
// Persist URL-driven active space so cold-starts at /channels/ resume on // Persist URL-driven active space so cold-starts at /channels/ resume on
// the same workspace. `useActiveSpace` (in ChannelsLanding) reads the // the same workspace. `useActiveSpace` (in ChannelsLanding) reads the
@ -60,10 +71,11 @@ export function Channels() {
}, [space.roomId]); }, [space.roomId]);
return ( return (
<PageNav resizable surface="surfaceVariant"> <PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
<ChannelsWorkspaceHorseshoe space={space}> <ChannelsWorkspaceHorseshoe space={space}>
<StreamHeader <StreamHeader
scrollRef={scrollRef} scrollRef={scrollRef}
pinKey="channels"
bottomPinned={ bottomPinned={
<> <>
<ChannelCreateRow space={space} /> <ChannelCreateRow space={space} />

View file

@ -17,14 +17,19 @@ export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
// header. // header.
// //
// `marginTop: -var(--vojo-safe-top)` extends the container UP over the // `marginTop: -var(--vojo-safe-top)` extends the container UP over the
// status-bar safe-top zone. Mirror of the same property in // status-bar safe-top zone, and the compensating
// `MobileSettingsHorseshoe.css.ts::container` — without it, the wrapped // `paddingTop: var(--vojo-safe-top)` on `appBody` keeps the wrapped
// StreamHeader's curtain (which positions `top: <negative>` when // channels list anchored at the same visual Y. Mirror of the same
// dragged past `closed`) is clipped by both `overflow: hidden` here // pair in `MobileSettingsHorseshoe.css.ts::container` — purpose is
// and `appBody`'s clipPath at the bottom edge of the status bar, so // (1) the workspace-sheet clip-path mask on `appBody` carves into an
// the lighter `SurfaceVariant.Container` strip from `PageNav-inner` // opaque surface that already paints through the status-bar strip,
// stays uncovered. The compensating `padding-top` lives on `appBody` // and (2) `appBody`'s bg paints the safe-top strip in the same
// so the wrapped channels list / tabs row stay visually anchored. // `SurfaceVariant.Container` tone as the pager's static header so the
// system-tray backdrop stays consistent across surfaces.
//
// The curtain itself never paints into the safe-top zone — pin
// gesture clamps it at `top: 0` of the stage, see
// `components/stream-header/geometry.ts::PIN_TRAVEL_PX`.
export const container = style({ export const container = style({
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',

View file

@ -24,7 +24,7 @@
// canonical horseshoe and are not re-litigated here. // canonical horseshoe and are not re-litigated here.
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'; import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useAtomValue } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { channelsWorkspaceSheetAtom } from '../../../state/channelsWorkspaceSheet'; import { channelsWorkspaceSheetAtom } from '../../../state/channelsWorkspaceSheet';
@ -32,6 +32,8 @@ import {
useCloseChannelsWorkspaceSheet, useCloseChannelsWorkspaceSheet,
useOpenChannelsWorkspaceSheet, useOpenChannelsWorkspaceSheet,
} from '../../../state/hooks/channelsWorkspaceSheet'; } from '../../../state/hooks/channelsWorkspaceSheet';
import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
import { mobileHorseshoeActiveAtom } from '../../../state/mobilePagerHeader';
import { VOJO_HORSESHOE_VOID_COLOR } from '../../../styles/horseshoe'; import { VOJO_HORSESHOE_VOID_COLOR } from '../../../styles/horseshoe';
import { WorkspaceSwitcherSheet } from './WorkspaceSwitcherSheet'; import { WorkspaceSwitcherSheet } from './WorkspaceSwitcherSheet';
import * as css from './ChannelsWorkspaceHorseshoe.css'; import * as css from './ChannelsWorkspaceHorseshoe.css';
@ -96,6 +98,11 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [containerHeightPx, setContainerHeightPx] = useState(0); const [containerHeightPx, setContainerHeightPx] = useState(0);
const [drag, setDrag] = useState<DragState | null>(null); const [drag, setDrag] = useState<DragState | null>(null);
// In pager mode the appBody bg must be transparent so the static
// pager header tabs (behind the swipe strip in DOM order) show
// through until covered by the rising chats curtain. See
// MobileSettingsHorseshoe.tsx for the matching pattern.
const inPagerMode = useMobilePagerPane() !== null;
// ResizeObserver on the wrapper — rail height is scoped to THIS // ResizeObserver on the wrapper — rail height is scoped to THIS
// column. PageNav width changes via the resizable handle on desktop // column. PageNav width changes via the resizable handle on desktop
@ -160,6 +167,17 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
const isDragging = drag !== null; const isDragging = drag !== null;
const horseshoeActive = expandedPx > 0; const horseshoeActive = expandedPx > 0;
// Bridge our local `horseshoeActive` (geometric) signal up to the
// pager-shared atom so `MobileTabsPagerHeader` can z-elevate the
// static header from the first frame of drag. Mirror of the same
// bridge in `MobileSettingsHorseshoe.tsx`. See the atom's docs in
// `state/mobilePagerHeader.ts` for the «no black flash» rationale.
const setMobileHorseshoeActive = useSetAtom(mobileHorseshoeActiveAtom);
useEffect(() => {
setMobileHorseshoeActive(horseshoeActive);
return () => setMobileHorseshoeActive(false);
}, [horseshoeActive, setMobileHorseshoeActive]);
const handleRef = useRef<HTMLDivElement>(null); const handleRef = useRef<HTMLDivElement>(null);
// Refs so the always-installed document listeners see the latest // Refs so the always-installed document listeners see the latest
@ -455,6 +473,14 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
clipPath: appBodyClipPath, clipPath: appBodyClipPath,
transition: appBodyTransition, transition: appBodyTransition,
overscrollBehaviorY: 'contain', overscrollBehaviorY: 'contain',
// See MobileSettingsHorseshoe.tsx for the full rationale:
// appBody is transparent in pager mode AT REST so the static
// pager header shows through, but flips back to its CSS-
// class `SurfaceVariant.Container` whenever the horseshoe is
// active so the container's `VOJO_HORSESHOE_VOID_COLOR` paint
// stays contained to the bottom carve cut-out and doesn't
// bleed into the chip-area band above the curtain.
backgroundColor: inPagerMode && !horseshoeActive ? 'transparent' : undefined,
}} }}
> >
{children} {children}

View file

@ -25,6 +25,7 @@ import {
import { StreamHeader } from '../../../components/stream-header'; import { StreamHeader } from '../../../components/stream-header';
import { DirectSelfRow } from './DirectSelfRow'; import { DirectSelfRow } from './DirectSelfRow';
import { MobileSettingsHorseshoe } from '../../../features/settings'; import { MobileSettingsHorseshoe } from '../../../features/settings';
import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
type ListItem = type ListItem =
| { kind: 'invite'; entry: DirectInviteEntry } | { kind: 'invite'; entry: DirectInviteEntry }
@ -205,13 +206,22 @@ export function Direct() {
overscan: 10, overscan: 10,
}); });
// In pager mode the PageNav surface bg would cover the pager's
// static header tabs (which sit behind the swipe strip in DOM order
// so the chats curtain can rise OVER them like a real blind). Skip
// the surface paint here in pager mode — the static header behind
// owns the visible safe-top + tabsRow tone. See
// `mobile-tabs-pager/style.css.ts::pagerStaticHeader` for the
// overlay contract.
const inPagerMode = useMobilePagerPane() !== null;
return ( return (
<PageNav resizable surface="surfaceVariant"> <PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
{/* MobileSettingsHorseshoe wraps the full DM column on mobile so the {/* MobileSettingsHorseshoe wraps the full DM column on mobile so the
Settings sheet can carve into the bottom of this pane. On non-mobile Settings sheet can carve into the bottom of this pane. On non-mobile
it's a pass-through. */} it's a pass-through. */}
<MobileSettingsHorseshoe> <MobileSettingsHorseshoe>
<StreamHeader scrollRef={scrollRef} bottomPinned={<DirectSelfRow />}> <StreamHeader scrollRef={scrollRef} bottomPinned={<DirectSelfRow />} pinKey="direct">
{noRoomToDisplay ? ( {noRoomToDisplay ? (
<DirectEmpty /> <DirectEmpty />
) : ( ) : (

View file

@ -20,3 +20,48 @@ export type MobilePagerCurtainControls = {
}; };
export const mobilePagerCurtainAtom = atom<MobilePagerCurtainControls | null>(null); export const mobilePagerCurtainAtom = atom<MobilePagerCurtainControls | null>(null);
// Per-tab pinned state for the StreamHeader curtain. Each listing
// surface (Direct / Channels / Bots) reads/writes by its own stable
// `pinKey` so the pin survives the route-driven listing-pane unmount
// that happens when the user taps into a Room and back: the atom
// outlives any individual StreamHeader instance.
//
// In-memory only (no localStorage). The pinned state is ephemeral UI
// state — user expects a fresh «header visible» default on cold start;
// surviving app restarts would be surprising.
//
// Map shape (keys are arbitrary stable strings, owned by each
// StreamHeader consumer):
// { direct: true, channels: false, bots: false, ... }
// Missing key ⇒ false. Each tab's pin lives independently — pinning
// Direct doesn't pin Channels.
export const curtainPinnedByTabAtom = atom<Record<string, boolean>>({});
// True while a horseshoe bottom sheet (settings on Direct, workspace
// switcher on Channels) is geometrically active — i.e. `expandedPx > 0`,
// which covers both the in-flight drag and the committed-open state.
//
// Source of truth for `MobileTabsPagerHeader`'s z-elevation: when this
// is true, the static pager header bumps to a positive z-index so it
// paints above the strip's stacking context and covers the safe-top +
// tabsRow band with `SurfaceVariant.Container`. Without this, the
// container's `VOJO_HORSESHOE_VOID_COLOR` paint that drives the carve
// would bleed up through the transparent strip stack into the system-
// tray strip.
//
// Tracking the GEOMETRIC signal (not the sheet-open atoms) is load-
// bearing: container bg flips to the void colour the moment a drag-
// up on `DirectSelfRow` crosses 0 px, but the sheet atom only commits
// after 80 px past the threshold. Driving elevation off the geometry
// keeps the static header z-above the void from the first frame of
// drag — no «pane goes black then header re-paints light blue» flash.
//
// Mount exclusivity: in pager mode all three listing surfaces stay
// mounted, but only one's horseshoe can have `horseshoeActive=true`
// because the pager's own swipe gesture is gated off whenever a sheet
// is open and the per-pane drag origin (DirectSelfRow / WorkspaceFooter)
// only receives touches on the active pane. Two horseshoes writing
// `true` at the same time is therefore unreachable through any user
// flow.
export const mobileHorseshoeActiveAtom = atom<boolean>(false);