From 31a3880de7008aa327d2fba836346ec83c95c0bd Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Tue, 19 May 2026 11:50:31 +0300 Subject: [PATCH] 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 --- .../mobile-tabs-pager/MobileTabsPager.tsx | 11 +- .../MobileTabsPagerHeader.tsx | 60 ++++++++- .../components/mobile-tabs-pager/style.css.ts | 80 +++++++++-- .../stream-header/StreamHeader.css.ts | 24 ++++ .../components/stream-header/StreamHeader.tsx | 53 +++++++- src/app/components/stream-header/geometry.ts | 48 +++++++ .../stream-header/useCurtainGesture.ts | 127 ++++++++++++++++-- .../stream-header/useCurtainState.ts | 83 +++++++++--- .../settings/MobileSettingsHorseshoe.css.ts | 30 +++-- .../settings/MobileSettingsHorseshoe.tsx | 46 ++++++- src/app/pages/client/bots/Bots.tsx | 9 +- src/app/pages/client/channels/Channels.tsx | 18 ++- .../ChannelsWorkspaceHorseshoe.css.ts | 21 +-- .../channels/ChannelsWorkspaceHorseshoe.tsx | 28 +++- src/app/pages/client/direct/Direct.tsx | 14 +- src/app/state/mobilePagerHeader.ts | 45 +++++++ 16 files changed, 618 insertions(+), 79 deletions(-) diff --git a/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx b/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx index dc4304e2..dd216c69 100644 --- a/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx +++ b/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx @@ -357,7 +357,16 @@ export function MobileTabsPager() { onSelectChannels={onSelectChannels} onSelectBots={onSelectBots} /> -
+ {/* `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. */} +
diff --git a/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx b/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx index e140150a..2a50e3ca 100644 --- a/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx +++ b/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx @@ -2,11 +2,22 @@ import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useAtomValue } from 'jotai'; 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 * as streamHeaderCss from '../stream-header/StreamHeader.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'; // Must match the `INLINE_FORM_ID` local constant in @@ -71,8 +82,53 @@ export function MobileTabsPagerHeader({ const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]); 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 ( -
+
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);