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({
position: 'absolute',
top: 0,
left: 0,
right: 0,
- zIndex: 10,
paddingTop: 'var(--vojo-safe-top, 0px)',
// 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
diff --git a/src/app/components/stream-header/StreamHeader.css.ts b/src/app/components/stream-header/StreamHeader.css.ts
index bb2da12d..fa676c07 100644
--- a/src/app/components/stream-header/StreamHeader.css.ts
+++ b/src/app/components/stream-header/StreamHeader.css.ts
@@ -12,6 +12,14 @@ import {
// Stage. Position-relative anchor. The header itself paints the
// 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({
position: 'relative',
flex: 1,
@@ -19,11 +27,22 @@ export const stage = style({
display: 'flex',
flexDirection: 'column',
backgroundColor: color.SurfaceVariant.Container,
+ selectors: {
+ '[data-pager-pane="true"] &': {
+ backgroundColor: 'transparent',
+ },
+ },
});
// 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.
+//
+// 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({
position: 'absolute',
top: 0,
@@ -35,6 +54,11 @@ export const header = style({
// everything below the tabs row when raised.
zIndex: 1,
backgroundColor: color.SurfaceVariant.Container,
+ selectors: {
+ '[data-pager-pane="true"] &': {
+ backgroundColor: 'transparent',
+ },
+ },
});
// Tabs row. Stays fully visible regardless of curtain position
diff --git a/src/app/components/stream-header/StreamHeader.tsx b/src/app/components/stream-header/StreamHeader.tsx
index 6111d387..22d52c47 100644
--- a/src/app/components/stream-header/StreamHeader.tsx
+++ b/src/app/components/stream-header/StreamHeader.tsx
@@ -9,15 +9,16 @@ import React, {
} from 'react';
import { useTranslation } from 'react-i18next';
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 { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths';
import { isNativePlatform } from '../../utils/capacitor';
import { useBotPresets } from '../../features/bots/catalog';
import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext';
import { MobilePagerCurtainControls, mobilePagerCurtainAtom } from '../../state/mobilePagerHeader';
+import { settingsSheetAtom } from '../../state/settingsSheet';
+import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
import * as css from './StreamHeader.css';
-import { CHIP_ROW_PX, TABS_ROW_PX } from './geometry';
import { Segment } from './Segment';
import { Chip } from './Chip';
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
// over the form (see commit 14ed080).
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 navigate = useNavigate();
const bots = useBotPresets();
@@ -66,13 +75,37 @@ export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeader
const inPagerMode = pagerPane !== null;
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({
scrollRef,
snap: curtain.snap,
+ pinned: curtain.pinned,
+ setPinned: curtain.setPinned,
setLiveDrag: curtain.setLiveDrag,
commit: curtain.commit,
+ disabled: gestureDisabled,
});
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
// commit happen in the same render pipeline and there's no
// 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.
// Guarded so unrelated transitionend events (e.g. children's own
@@ -347,5 +388,3 @@ export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeader
);
}
-
-export { TABS_ROW_PX, CHIP_ROW_PX };
diff --git a/src/app/components/stream-header/geometry.ts b/src/app/components/stream-header/geometry.ts
index 9afdc3d3..f2e49d86 100644
--- a/src/app/components/stream-header/geometry.ts
+++ b/src/app/components/stream-header/geometry.ts
@@ -9,10 +9,26 @@
// 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
+// 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
// peek = TABS_ROW_PX + 2·CHIP_ROW_PX + CHIP_GAP_PX
// + 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.
@@ -69,3 +85,35 @@ 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.
+// 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;
diff --git a/src/app/components/stream-header/useCurtainGesture.ts b/src/app/components/stream-header/useCurtainGesture.ts
index aadf0fc9..cf154a7b 100644
--- a/src/app/components/stream-header/useCurtainGesture.ts
+++ b/src/app/components/stream-header/useCurtainGesture.ts
@@ -5,6 +5,9 @@ import {
COMMIT_THRESHOLD,
DIRECTION_DEAD_ZONE_PX,
PEEK_TRAVEL_PX,
+ PIN_COMMIT_THRESHOLD,
+ PIN_RUBBER_BAND,
+ PIN_TRAVEL_PX,
RUBBER_BAND,
} from './geometry';
import { CurtainSnap, isFormSnap } from './useCurtainState';
@@ -17,6 +20,15 @@ type Args = {
// Current snap stop. Read at touchstart to decide gesture meaning.
// Mirrored into a ref so the listener (bound once) reads fresh values.
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
// `liveDragPx` from the parent state too, so React drives the
// curtain's `top` re-render — no direct DOM writes, no inline-vs-
@@ -24,25 +36,61 @@ type Args = {
setLiveDrag: (px: number, dragging: boolean) => void;
// Commit a new snap stop on release.
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
// listeners aren't attached at all.
//
-// Peek path: drag down from `closed` rubber-bands the live delta and
-// on release past the threshold commits to `peek` (both chips
-// revealed in one motion). Drag UP from `peek` retreats to `closed`.
+// Peek path: drag down from `closed` (not pinned) rubber-bands the live
+// delta and on release past the threshold commits to `peek` (both
+// 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
// 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 —
// always reads the freshest value without re-attaching.
const snapRef = useRef
(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(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(() => {
if (!isNativePlatform()) return undefined;
+ if (disabled) return undefined;
const list = scrollRef.current;
if (!list) return undefined;
@@ -92,6 +140,7 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
const delta = e.touches[0].clientY - startY;
const deltaX = startX !== null ? e.touches[0].clientX - startX : 0;
const currentSnap = snapRef.current;
+ const currentPinned = pinnedRef.current;
// Resolve a direction once the finger crosses the dead-zone.
if (direction === null) {
@@ -110,21 +159,27 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
}
direction = delta > 0 ? 'down' : 'up';
- // Direction guards: nothing higher than `closed`; nothing
- // lower than `peek`; form snaps only close (up).
- if (currentSnap === 'closed' && direction === 'up') {
+ // Direction guards:
+ // - pinned ⇒ only DOWN (unpin); UP would try to push past
+ // 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;
startY = null;
direction = null;
return;
}
- if (currentSnap === 'peek' && direction === 'down') {
+ if (!currentPinned && currentSnap === 'peek' && direction === 'down') {
startX = null;
startY = null;
direction = null;
return;
}
- if (isFormSnap(currentSnap) && direction === 'down') {
+ if (!currentPinned && isFormSnap(currentSnap) && direction === 'down') {
startX = null;
startY = null;
direction = null;
@@ -135,11 +190,27 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
engaged = true;
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
// at 0 so an accidental downward jitter doesn't push the
// curtain below its resting position.
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 {
// Peek: rubber-banded BOTH directions. Down (delta > 0) reveals
// 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 currentPinned = pinnedRef.current;
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) {
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 {
// Single-stage peek toggle. Threshold is COMMIT_THRESHOLD of
// 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
// one batched update — React renders the curtain at the new
// resting top with the snap transition re-enabled.
@@ -216,7 +312,10 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args
list.removeEventListener('touchcancel', onTouchCancel);
};
// 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
- }, [scrollRef, setLiveDrag, commit]);
+ }, [scrollRef, setLiveDrag, commit, disabled]);
}
diff --git a/src/app/components/stream-header/useCurtainState.ts b/src/app/components/stream-header/useCurtainState.ts
index d1a4398e..cabd03cd 100644
--- a/src/app/components/stream-header/useCurtainState.ts
+++ b/src/app/components/stream-header/useCurtainState.ts
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { useAtom } from 'jotai';
+import { curtainPinnedByTabAtom } from '../../state/mobilePagerHeader';
import {
CHIP_GAP_PX,
CHIP_ROW_PX,
@@ -17,9 +19,7 @@ export type CurtainSnap =
| 'form-search' // full search form revealed
| 'form-chat'; // full new-chat form revealed
-export const isFormSnap = (
- snap: CurtainSnap
-): snap is 'form-search' | 'form-chat' =>
+export const isFormSnap = (snap: CurtainSnap): snap is 'form-search' | 'form-chat' =>
snap === 'form-search' || snap === 'form-chat';
export const isPeekSnap = (snap: CurtainSnap): snap is 'peek' => snap === 'peek';
@@ -31,6 +31,18 @@ export type ActiveForm = 'search' | 'chat' | null;
export type CurtainState = {
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;
// 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
@@ -80,13 +92,7 @@ export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
case 'closed':
return TABS_ROW_PX;
case 'peek':
- return (
- TABS_ROW_PX +
- CHIP_ROW_PX +
- CHIP_GAP_PX +
- CHIP_ROW_PX +
- CURTAIN_BREATHER_PX
- );
+ return TABS_ROW_PX + CHIP_ROW_PX + CHIP_GAP_PX + CHIP_ROW_PX + CURTAIN_BREATHER_PX;
case 'form-search':
case 'form-chat':
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('closed');
const [activeForm, setActiveForm] = useState(null);
const [formHeightPx, setFormHeightPx] = useState(null);
const [liveDragPx, setLiveDragPx] = useState(0);
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(null);
- const open = useCallback((form: 'search' | 'chat') => {
- setActiveForm(form);
- setSnap(form === 'search' ? 'form-search' : 'form-chat');
- setLiveDragPx(0);
- setIsDragging(false);
- }, []);
+ const setPinned = useCallback(
+ (next: boolean) => {
+ if (pinKey) {
+ setPinnedMap((prev) => {
+ // 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(() => {
setSnap('closed');
@@ -180,6 +225,8 @@ export function useCurtainState(): CurtainState {
return useMemo(
() => ({
snap,
+ pinned,
+ setPinned,
activeForm,
liveDragPx,
isDragging,
@@ -193,6 +240,8 @@ export function useCurtainState(): CurtainState {
}),
[
snap,
+ pinned,
+ setPinned,
activeForm,
liveDragPx,
isDragging,
diff --git a/src/app/features/settings/MobileSettingsHorseshoe.css.ts b/src/app/features/settings/MobileSettingsHorseshoe.css.ts
index aaec1647..af8cf5b4 100644
--- a/src/app/features/settings/MobileSettingsHorseshoe.css.ts
+++ b/src/app/features/settings/MobileSettingsHorseshoe.css.ts
@@ -19,16 +19,26 @@ export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
// (PageNav's inner column for the Direct route).
//
// `marginTop: -var(--vojo-safe-top)` extends the container UP over the
-// status-bar safe-top zone reserved by `PageNav` via `padding-top`. With
-// this offset the wrapped StreamHeader's `curtain` (which positions
-// `top: ` when dragged past `closed`) can paint into the
-// status-bar zone — without it, both `overflow: hidden` here and the
-// `clipPath` on `appBody` would clip those pixels at the bottom of
-// the status-bar strip and the strip would stay uncovered, breaking
-// parity with Bots / ChannelsRoot (where StreamHeader is a direct
-// child of PageNav and no such wrapper clip exists). The compensating
-// `padding-top` lives on `appBody` so the wrapped DM list / tabs row
-// stay visually anchored at the same Y as before the shift.
+// status-bar safe-top zone reserved by `PageNav` via `padding-top`,
+// and the compensating `paddingTop: var(--vojo-safe-top)` on `appBody`
+// keeps the wrapped DM list anchored at the same visual Y as before
+// the shift. The combination has two load-bearing effects:
+//
+// (1) The settings-sheet clip-path mask on `appBody` carves rounded
+// BL/BR into an opaque surface that already paints THROUGH the
+// status-bar strip — without the upward extension the carve
+// would visibly stop at the bottom of the system-tray strip.
+// (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({
position: 'relative',
display: 'flex',
diff --git a/src/app/features/settings/MobileSettingsHorseshoe.tsx b/src/app/features/settings/MobileSettingsHorseshoe.tsx
index 19e3f492..c5a654fe 100644
--- a/src/app/features/settings/MobileSettingsHorseshoe.tsx
+++ b/src/app/features/settings/MobileSettingsHorseshoe.tsx
@@ -48,12 +48,14 @@
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
-import { useAtomValue } from 'jotai';
+import { useAtomValue, useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import { settingsSheetAtom } from '../../state/settingsSheet';
import { useCloseSettingsSheet, useOpenSettingsSheet } from '../../state/hooks/settingsSheet';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
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 { Settings } from './Settings';
import * as css from './MobileSettingsHorseshoe.css';
@@ -116,6 +118,15 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
const sheet = useAtomValue(settingsSheetAtom);
const openSheet = useOpenSettingsSheet();
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(null);
const [viewportHeight, setViewportHeight] = useState(() =>
@@ -188,6 +199,17 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
const isDragging = drag !== null;
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(null);
// Refs so the always-installed event handlers see the latest state
@@ -536,6 +558,28 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
clipPath: appBodyClipPath,
transition: appBodyTransition,
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}
diff --git a/src/app/pages/client/bots/Bots.tsx b/src/app/pages/client/bots/Bots.tsx
index 498f12c6..6576ce0e 100644
--- a/src/app/pages/client/bots/Bots.tsx
+++ b/src/app/pages/client/bots/Bots.tsx
@@ -3,6 +3,7 @@ import { Box, color, config, toRem } from 'folds';
import { useMatch } from 'react-router-dom';
import { PageNav, PageNavContent } from '../../../components/page';
import { StreamHeader } from '../../../components/stream-header';
+import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
import { useBotPresets } from '../../../features/bots/catalog';
import type { BotPreset } from '../../../features/bots/catalog';
import { BotCard } from '../../../features/bots/BotCard';
@@ -24,10 +25,14 @@ export function Bots() {
// only) can recognise list scrollTop=0 and engage the curtain peek.
// Icons + click flows work on every platform regardless.
const scrollRef = useRef(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 (
-
-
+
+
(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 (
-
-
+
+ {/* 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. */}
+
@@ -46,6 +56,7 @@ export function ChannelsRootNav() {
export function Channels() {
const space = useSpace();
const scrollRef = useRef(null);
+ const inPagerMode = useMobilePagerPane() !== null;
// Persist URL-driven active space so cold-starts at /channels/ resume on
// the same workspace. `useActiveSpace` (in ChannelsLanding) reads the
@@ -60,10 +71,11 @@ export function Channels() {
}, [space.roomId]);
return (
-
+
diff --git a/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts b/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts
index 085928f6..26a16fb2 100644
--- a/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts
+++ b/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts
@@ -17,14 +17,19 @@ export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
// header.
//
// `marginTop: -var(--vojo-safe-top)` extends the container UP over the
-// status-bar safe-top zone. Mirror of the same property in
-// `MobileSettingsHorseshoe.css.ts::container` — without it, the wrapped
-// StreamHeader's curtain (which positions `top: ` when
-// dragged past `closed`) is clipped by both `overflow: hidden` here
-// and `appBody`'s clipPath at the bottom edge of the status bar, so
-// the lighter `SurfaceVariant.Container` strip from `PageNav-inner`
-// stays uncovered. The compensating `padding-top` lives on `appBody`
-// so the wrapped channels list / tabs row stay visually anchored.
+// status-bar safe-top zone, and the compensating
+// `paddingTop: var(--vojo-safe-top)` on `appBody` keeps the wrapped
+// channels list anchored at the same visual Y. Mirror of the same
+// pair in `MobileSettingsHorseshoe.css.ts::container` — purpose is
+// (1) the workspace-sheet clip-path mask on `appBody` carves into an
+// opaque surface that already paints through the status-bar strip,
+// and (2) `appBody`'s bg paints the safe-top strip in the same
+// `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({
position: 'relative',
display: 'flex',
diff --git a/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.tsx b/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.tsx
index 0ecfe16a..7a16af03 100644
--- a/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.tsx
+++ b/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.tsx
@@ -24,7 +24,7 @@
// canonical horseshoe and are not re-litigated here.
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
-import { useAtomValue } from 'jotai';
+import { useAtomValue, useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import { Room } from 'matrix-js-sdk';
import { channelsWorkspaceSheetAtom } from '../../../state/channelsWorkspaceSheet';
@@ -32,6 +32,8 @@ import {
useCloseChannelsWorkspaceSheet,
useOpenChannelsWorkspaceSheet,
} 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 { WorkspaceSwitcherSheet } from './WorkspaceSwitcherSheet';
import * as css from './ChannelsWorkspaceHorseshoe.css';
@@ -96,6 +98,11 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
const containerRef = useRef(null);
const [containerHeightPx, setContainerHeightPx] = useState(0);
const [drag, setDrag] = useState(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
// 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 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(null);
// Refs so the always-installed document listeners see the latest
@@ -455,6 +473,14 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
clipPath: appBodyClipPath,
transition: appBodyTransition,
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}
diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx
index 54e20935..6e68dd08 100644
--- a/src/app/pages/client/direct/Direct.tsx
+++ b/src/app/pages/client/direct/Direct.tsx
@@ -25,6 +25,7 @@ import {
import { StreamHeader } from '../../../components/stream-header';
import { DirectSelfRow } from './DirectSelfRow';
import { MobileSettingsHorseshoe } from '../../../features/settings';
+import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
type ListItem =
| { kind: 'invite'; entry: DirectInviteEntry }
@@ -205,13 +206,22 @@ export function Direct() {
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 (
-
+
{/* MobileSettingsHorseshoe wraps the full DM column on mobile so the
Settings sheet can carve into the bottom of this pane. On non-mobile
it's a pass-through. */}
- }>
+ } pinKey="direct">
{noRoomToDisplay ? (
) : (
diff --git a/src/app/state/mobilePagerHeader.ts b/src/app/state/mobilePagerHeader.ts
index c0527331..5f690bb2 100644
--- a/src/app/state/mobilePagerHeader.ts
+++ b/src/app/state/mobilePagerHeader.ts
@@ -20,3 +20,48 @@ export type MobilePagerCurtainControls = {
};
export const mobilePagerCurtainAtom = atom(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>({});
+
+// 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(false);