feat(refresh): host the mascot chrome as a strip singleton and draw the radar rings on one canvas, zeroing WebView tile-memory warnings

This commit is contained in:
heaven 2026-06-11 22:35:33 +03:00
parent 7bf177751b
commit 5c67c4d78e
16 changed files with 1133 additions and 216 deletions

View file

@ -202,7 +202,7 @@ Slate-based. `Editor.tsx` (Slate root — preserve), `Editor.preview.tsx`, `Elem
### Navigation / list (mostly NEW dirs)
- `nav/` (**NEW**, heavily used) — generic list-row primitives (`NavCategory`, `NavCategoryHeader`, `NavItem`/`NavLink`, `NavItemContent`, `NavItemOptions`, `NavEmptyLayout`). The lower-level layer beneath `features/room-nav/`.
- `stream-header/` (**NEW**) — the **tab curtain header** for the Direct/Channels/Bots listing tabs (`StreamHeader` + `Chip`/`Segment` + `useCurtain*` gestures + `forms/InlineRoomSearch`). **Not** the room header — don't confuse with `RoomViewHeaderDm`. The Plus/new-chat action is **gone on Direct** (people are found in search); the Plus only renders where a tab supplies a `primaryAction` (Channels' create-channel/community). The curtain has a single form (`form-search`); its peek geometry is **chip-count-aware**`peekTravelPx(chipRows)` (1 on Direct, 2 on Channels) is threaded into `snapTopPx` + both gesture hooks so rest position and commit scale stay in lockstep.
- `stream-header/` (**NEW**) — the **tab curtain header** for the Direct/Channels/Bots listing tabs (`StreamHeader` + `Segment` + `useCurtain*` gestures + `RefreshMascot`/`RadarPulse` + `forms/InlineRoomSearch`). **Not** the room header — don't confuse with `RoomViewHeaderDm`. The Plus/new-chat action is **gone on Direct** (people are found in search); the Plus only renders where a tab supplies a `primaryAction` (Channels' create-channel/community). The curtain is a snap machine (`useCurtainState`: `closed` / momentary `refresh` / `form-search`, plus a per-tab `pinned` overlay in `curtainPinnedByTabAtom`); the old chip-peek geometry (`Chip`, `peekTravelPx`) is **gone** (commit 901bec2e) — pulling down past `REFRESH_COMMIT_PX` commits a pull-to-refresh that reveals the dancing-mascot card + radar-pulse rings (`MASCOT_CARD_PX` below the tabs row) and auto-closes after /sync recovers, aligned to the dance-loop boundary. The rings are ONE transparent 2D `<canvas>` redrawn per rAF (`RadarPulse.tsx` — an untiled TextureLayer; the previous CSS-animated SVG circles each became their own peak-scale cc layer and blew the WebView tile budget — corrected root-cause postmortem in that file). In pager mode the mascot/rings render ONCE in a strip-hosted singleton (`mobile-tabs-pager/PagerRefreshSingleton`, counter-translated against the strip via the inline `counterTx` prop, compositor-transitioned in lockstep with the strip) — one screen-static instance for all tabs, shown while ANY tab reveals (`curtainRefreshRevealByTabAtom`), hidden behind active forms/horseshoes; it MUST stay inside the strip subtree — see the tile-memory postmortem on `pagerRefreshSingleton` in `mobile-tabs-pager/style.css.ts`.
- `mobile-tabs-pager/` (**NEW**) — the mobile swipe pager (see Routing).
- `sidebar/``Sidebar` (66px wrapper), `SidebarItem`, `SidebarStack`, `SidebarStackSeparator`, `SidebarContent` (currently mounted only via dead `SidebarNav`).
- `virtualizer/` (`VirtualTile`), `scroll-top-container/`.

View file

@ -22,6 +22,7 @@ import { settingsSheetAtom } from '../../state/settingsSheet';
import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
import { MobilePagerPaneProvider, MobilePagerTab } from './MobilePagerPaneContext';
import { MobileTabsPagerHeader } from './MobileTabsPagerHeader';
import { PagerRefreshSingleton } from './PagerRefreshSingleton';
import { useMobileTabsPagerGesture } from './useMobileTabsPagerGesture';
import { PAGER_EASING, PAGER_TRANSITION_MS, PANE_GAP_PX } from './geometry';
import * as css from './style.css';
@ -365,24 +366,67 @@ export function MobileTabsPager({ asBackdrop = false }: MobileTabsPagerProps) {
// Memoised so the inline object identity is stable when dragPx
// doesn't change — avoids extra child re-renders when other state
// updates (e.g. atom subscription) tick the parent.
//
// `counterTx` is the exact negation of the strip's X — handed to the
// strip-hosted refresh singleton as a PROP so its counter-transform
// is a direct inline `transform` on a composited element, transitioned
// with the IDENTICAL timing string below. Both transitions then run on
// the COMPOSITOR in lockstep. (The previous design rode a registered
// `--vojo-strip-tx` @property transition — custom-property
// interpolation runs on the MAIN thread, and the `navigate()` commit
// at slide start reliably blocks it for the first frames of every
// swipe commit: the mascot visibly rode the strip, then snapped back.
// Same-commit inline styles + compositor transitions remove that
// failure mode entirely.)
const stripTx = `calc(${-visualIdx * 100}vw - ${visualIdx * PANE_GAP_PX}px + ${visualDragPx}px)`;
const counterTx = `calc(${visualIdx * 100}vw + ${visualIdx * PANE_GAP_PX}px - ${visualDragPx}px)`;
// Transition suppressed while a finger drag is in flight (the strip
// follows the finger 1:1) AND for the single frame after a tap-driven
// commit (`instantSwitch`) so segment taps snap to the target tab
// without the 280ms slide. Swipe commits leave `instantSwitch` false
// so the slide-to-target animation plays. Shared verbatim with the
// refresh singleton so the two transforms can never desync.
const stripTransition =
dragging || instantSwitch ? 'none' : `transform ${PAGER_TRANSITION_MS}ms ${PAGER_EASING}`;
const stripStyle = useMemo<React.CSSProperties>(
() => ({
width: `calc(${tabs.length * 100}vw + ${(tabs.length - 1) * PANE_GAP_PX}px)`,
transform: `translate3d(calc(${-visualIdx * 100}vw - ${
visualIdx * PANE_GAP_PX
}px + ${visualDragPx}px), 0, 0)`,
// Transition suppressed while a finger drag is in flight (the
// strip follows the finger 1:1) AND for the single frame after
// a tap-driven commit (`instantSwitch`) so segment taps snap to
// the target tab without the 280ms slide. Swipe commits leave
// `instantSwitch` false so the slide-to-target animation plays.
transition:
dragging || instantSwitch ? 'none' : `transform ${PAGER_TRANSITION_MS}ms ${PAGER_EASING}`,
transform: `translate3d(${stripTx}, 0, 0)`,
transition: stripTransition,
gap: `${PANE_GAP_PX}px`,
}),
[tabs.length, visualIdx, visualDragPx, dragging, instantSwitch]
[tabs.length, stripTx, stripTransition]
);
// True from the moment a finger drag RELEASES (commit OR spring-back)
// until the strip's ~PAGER_TRANSITION_MS slide has settled. Feeds the
// refresh singleton's `pagerAnimating` gate: `pendingTargetTab` clears
// as soon as the URL catches up (~1 frame after navigate), and a
// spring-back release never sets it at all — yet both play a ≈280ms
// slide during which a lingering mascot-card sliver could sweep past
// the transparent inter-pane gap. Timer-based (not transitionend) so a
// missed event can't strand the flag. Tap commits (instantSwitch) jump
// without sliding and never pass through `dragging`, so they need no
// hold.
const [stripSettling, setStripSettling] = useState(false);
const wasDraggingRef = useRef(false);
useEffect(() => {
if (dragging) {
wasDraggingRef.current = true;
setStripSettling(false);
return undefined;
}
if (!wasDraggingRef.current) return undefined;
wasDraggingRef.current = false;
setStripSettling(true);
const id = window.setTimeout(() => setStripSettling(false), PAGER_TRANSITION_MS + 100);
return () => window.clearTimeout(id);
}, [dragging]);
// Strip-in-motion signal for the refresh singleton (gates only its
// post-close card linger — see `pagerAnimating` on the component).
const stripAnimating = dragging || pendingTargetTab !== null || stripSettling;
// Per-pane context values memoised separately so each pane's
// `useMobilePagerPane()` consumer (the inner StreamHeader) only
// re-runs when ITS isActive flag toggles, not every time the parent
@ -410,6 +454,31 @@ export function MobileTabsPager({ asBackdrop = false }: MobileTabsPagerProps) {
// active. Map it back to a Tab name and pass down.
const activeTab: Tab = tabs[urlActiveIdx] ?? 'direct';
// Memoised PANE ELEMENTS — referentially stable children let React
// skip reconciling the three listing subtrees on every parent
// re-render. Load-bearing for swipe smoothness: the drag updates
// `dragPx` state on every touchmove, re-rendering this component at
// input frequency (60120 Hz on Android), and without stable
// children each frame would also re-render Direct + Channels + Bots
// (virtualized DM list included) in the same frame that has to
// commit the strip transform.
const directPane = useMemo(() => <Direct />, []);
const channelsPane = useMemo(
() => (
<ChannelsModeProvider value>
{activeSpace ? (
<SpaceProvider key={activeSpace.roomId} value={activeSpace}>
<Channels />
</SpaceProvider>
) : (
<ChannelsRootNav />
)}
</ChannelsModeProvider>
),
[activeSpace]
);
const botsPane = useMemo(() => <Bots />, []);
// Invalid URL space — defer to the existing route tree which
// handles unjoined / unknown spaces via JoinBeforeNavigate. All
// hooks above must run unconditionally for rules-of-hooks
@ -441,29 +510,32 @@ export function MobileTabsPager({ asBackdrop = false }: MobileTabsPagerProps) {
pixel the curtain isn't covering. See `pagerStaticHeader` in
style.css.ts for the full overlay contract. */}
<div className={css.strip} style={stripStyle} data-pager-pane="true">
{/* ONE screen-static pull-to-refresh chrome for the whole app
mascot card + radar rings hosted INSIDE the strip's
transform subtree (tile-memory contract) and counter-
translated by `counterTx` (the strip X negated, same-commit
inline style + identical compositor transition) so it never
rides a pane. DOM-FIRST child: as the first positioned z:auto
participant of the strip's stacking context it paints below
every pane's positioned content curtains and sheets cover
it with no z-index machinery. See
style.css.ts::pagerRefreshSingleton for the full contract.
`pagerAnimating` gates only the post-close card linger (the
gap sweep), never the live reveal. */}
<PagerRefreshSingleton
pagerAnimating={stripAnimating}
counterTx={counterTx}
transition={stripTransition}
/>
<MobilePagerPaneProvider value={directPaneInfo}>
<PaneSlot isActive={directPaneInfo.isActive}>
<Direct />
</PaneSlot>
<PaneSlot isActive={directPaneInfo.isActive}>{directPane}</PaneSlot>
</MobilePagerPaneProvider>
<MobilePagerPaneProvider value={channelsPaneInfo}>
<PaneSlot isActive={channelsPaneInfo.isActive}>
<ChannelsModeProvider value>
{activeSpace ? (
<SpaceProvider key={activeSpace.roomId} value={activeSpace}>
<Channels />
</SpaceProvider>
) : (
<ChannelsRootNav />
)}
</ChannelsModeProvider>
</PaneSlot>
<PaneSlot isActive={channelsPaneInfo.isActive}>{channelsPane}</PaneSlot>
</MobilePagerPaneProvider>
{showBots && (
<MobilePagerPaneProvider value={botsPaneInfo}>
<PaneSlot isActive={botsPaneInfo.isActive}>
<Bots />
</PaneSlot>
<PaneSlot isActive={botsPaneInfo.isActive}>{botsPane}</PaneSlot>
</MobilePagerPaneProvider>
)}
</div>

View file

@ -8,7 +8,9 @@ import {
mobilePagerCurtainAtom,
} from '../../state/mobilePagerHeader';
import { Segment } from '../stream-header/Segment';
import { INLINE_FORM_ID } from '../stream-header/StreamHeader';
import * as streamHeaderCss from '../stream-header/StreamHeader.css';
import type { MobilePagerTab } from './MobilePagerPaneContext';
import * as css from './style.css';
// Positive z-index applied to the static pager header when any
@ -18,15 +20,10 @@ import * as css from './style.css';
// 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
// `StreamHeader.tsx`. The shared static header's action icons are
// `aria-controls`-linked to the form region that the active pane's
// StreamHeader renders inside its curtain — mirroring the original
// in-pane buttons' ARIA semantics so assistive tech still announces
// the relationship correctly. Keep the two literals in lockstep.
const INLINE_FORM_ID = 'stream-header-inline-form';
// The pager's tab union — imported, not re-declared, so the
// `pinnedByTab[activeTab]` read below stays type-aligned with the
// per-tab atoms keyed by StreamHeader's `pinKey`.
type Tab = MobilePagerTab;
type MobileTabsPagerHeaderProps = {
showBots: boolean;

View file

@ -0,0 +1,192 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAtomValue, useSetAtom } from 'jotai';
import { isNativePlatform } from '../../utils/capacitor';
import {
MobilePagerMascotApi,
mobileHorseshoeActiveAtom,
mobilePagerAnyRevealAtom,
mobilePagerFormMountedAtom,
mobilePagerMascotApiAtom,
} from '../../state/mobilePagerHeader';
import { RefreshMascot } from '../stream-header/RefreshMascot';
import { RadarPulse, useEchoes } from '../stream-header/RadarPulse';
import { CURTAIN_SNAP_MS } from '../stream-header/geometry';
import * as css from './style.css';
type PagerRefreshSingletonProps = {
// True while the strip is moving horizontally — a finger drag, the
// commit-slide, or the spring-back settle. Used ONLY to suppress the
// post-close card linger (see `cardLinger` below): the lingering card
// would otherwise sweep past the transparent inter-pane gap as a
// visible sliver. While a tab IS revealing, the card intentionally
// stays through a swipe — that's the whole «mascot doesn't ride the
// panes» point.
pagerAnimating: boolean;
// The strip's live X, NEGATED (computed by MobileTabsPager next to
// the strip transform). Applied as a direct inline `transform` on the
// promoted host so the counter-translation is committed in the SAME
// React style flush as the strip's transform and — during the 280ms
// commit-slide — transitions on the COMPOSITOR in lockstep with it.
// (A registered-@property var was tried first: custom-property
// interpolation runs on the main thread, and the navigate() commit at
// slide start froze it for the first frames of every swipe commit —
// the mascot visibly rode the strip, then snapped back.)
counterTx: string;
// The strip's transition string, verbatim (`none` while dragging /
// instant-switching, the 280ms slide otherwise) — single source so
// the two transforms can never desync.
transition: string;
};
// The pull-to-refresh chrome singleton — renders the dancing mascot +
// radar-pulse rings ONCE as the strip's first child, counter-translated
// against the strip's live translation so it stays SCREEN-static while
// the panes slide (see `style.css.ts::pagerRefreshSingleton` for the
// hosting / tile-memory / paint-order contract). The pull-to-refresh is
// an app-wide /sync poke, and the user reads the mascot as belonging to
// the app, not to one tab. The per-pane StreamHeaders render no mascot
// of their own in pager mode; each pane publishes its reveal state into
// `curtainRefreshRevealByTabAtom` and this singleton paints while ANY
// tab is revealing (an inactive tab's curtain legitimately rests at
// `refresh` through its auto-close dwell — keeping the chrome alive for
// it makes a swipe-away AND a return swipe mid-refresh seamless, no
// dance restart).
//
// Memoised: the parent pager re-renders on every horizontal-drag frame;
// the single primitive prop plus boolean-derived atoms keep this
// component to actual flips.
export const PagerRefreshSingleton = React.memo(
({ pagerAnimating, counterTx, transition }: PagerRefreshSingletonProps) => {
const { t } = useTranslation();
const anyRevealing = useAtomValue(mobilePagerAnyRevealAtom);
// Hide while the ACTIVE pane's inline search form is MOUNTED (not
// just while its snap is a form snap): this singleton paints behind
// the panes' content, and the form band has no opaque backdrop in
// pager mode — a mascot kept alive by ANOTHER tab's in-flight
// refresh (or by the linger) would show through the still-visible
// CLOSING form during its 280480ms slide-up window.
const formMounted = useAtomValue(mobilePagerFormMountedAtom);
// Hide while a horseshoe sheet is geometrically active. The sheet
// flips its pane-covering appBody OPAQUE on the first frame of drag
// (void-containment contract — see MobileSettingsHorseshoe), and
// appBody is a positioned later-in-tree sibling inside the pane,
// i.e. it paints over this singleton anyway. Unmounting on the same
// signal makes the hand-off explicit and stops the dance <video>
// decoding behind an opaque surface. Pre-singleton, the in-pane
// card stayed visible above appBody during a sheet drag; that
// nicety is consciously traded away — the overlap (sheet opened
// during the ≤8s refresh dwell) is rare, cosmetic, and
// self-corrects when the refresh auto-closes under the sheet.
const horseshoeActive = useAtomValue(mobileHorseshoeActiveAtom);
const revealing = anyRevealing && !formMounted && !horseshoeActive;
// The dance <video> while mounted (null while the reduced-motion /
// resting poster is shown). Handed to the pane's StreamHeader
// through the api atom so its auto-close can align to a dance-loop
// boundary — same contract as the in-stage `mascotVideoRef`.
const videoRef = useRef<HTMLVideoElement>(null);
// Echo state lives here (not in the per-pane StreamHeaders) so the
// one-shot rings share the singleton's canvas. NB an echo spawned
// just before close survives ~4.1s in this state; on a quick
// re-reveal it RESUMES mid-pass (echo phases are absolute
// `performance.now()` timestamps — see `EchoPulse`), it doesn't
// replay from zero like the old CSS-animation circles did.
const { echoes, spawnEcho } = useEchoes();
// Read once at mount, mirroring `RefreshMascot` / `StreamHeader` —
// this singleton is native-only + aria-hidden decoration, so a
// mid-session OS toggle not re-evaluating here is acceptable.
const reducedMotion = useMemo(
() =>
typeof window !== 'undefined' &&
!!window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
[]
);
// Publish the imperative channel (stable members → stable object, so
// the StreamHeader effects that consume it don't re-run spuriously).
// Compare-and-clear on unmount, mirroring `mobilePagerCurtainAtom`.
const api = useMemo<MobilePagerMascotApi>(
() => ({ getVideo: () => videoRef.current, spawnEcho }),
[spawnEcho]
);
const setApi = useSetAtom(mobilePagerMascotApiAtom);
useEffect(() => {
setApi(api);
return () => {
setApi((prev) => (prev === api ? null : prev));
};
}, [api, setApi]);
// Keep the mascot CARD mounted through the curtain's close animation.
// `revealing` flips false the instant the snap commits to `closed`,
// but the curtain takes CURTAIN_SNAP_MS to slide up over the band —
// unmounting the card immediately would visibly vanish the mascot
// before the curtain covers it. The in-stage variant never had this
// problem (its card is permanently mounted in the header); here the
// card must NOT be permanently visible, or it would peek through the
// inter-pane gap during plain tab swipes at rest. For the same
// reason the linger itself is suppressed (a) while the strip is
// moving (`pagerAnimating` — the gap would sweep a sliver of the
// card across the screen) and (b) under the form / horseshoe gates
// above, where `revealing` is forced false while the band isn't
// actually closing. The rings don't linger — parity with the
// in-stage layer, which unmounts the pulse at close-commit. During
// the linger the card renders `revealing=false`, i.e. the still
// poster.
const [cardLinger, setCardLinger] = useState(false);
useEffect(() => {
if (revealing) {
setCardLinger(true);
return undefined;
}
if (!cardLinger) return undefined;
const timer = window.setTimeout(() => setCardLinger(false), CURTAIN_SNAP_MS + 120);
return () => window.clearTimeout(timer);
}, [revealing, cardLinger]);
const showCard =
revealing || (cardLinger && !pagerAnimating && !formMounted && !horseshoeActive);
const showRings = revealing && !reducedMotion;
// The pager only mounts on mobile + native, so this is belt-and-
// suspenders (and keeps the hooks above unconditional).
if (!isNativePlatform()) return null;
// Zero DOM / zero layers during plain swipes — nothing can peek
// through the transparent inter-pane gap.
if (!showCard && !showRings) return null;
return (
<div
className={css.pagerRefreshSingleton}
// `translateZ(0)` promotes the host so the transition above runs
// compositor-side; the layer itself is cheap (the host paints
// nothing — tiles materialise only for the card band; the rings
// canvas is its own untiled TextureLayer, see RadarPulse.tsx).
style={{ transform: `translateX(${counterTx}) translateZ(0)`, transition }}
aria-hidden="true"
>
{showCard && (
<div className={css.pagerMascotSlot}>
<RefreshMascot
caption={t('Direct.refreshing')}
revealing={revealing}
videoRef={videoRef}
/>
</div>
)}
{/* Rings paint AFTER (= over) the mascot card, matching the
in-stage stacking where the pulse layer sits above the header
that hosts the card. */}
{showRings && <RadarPulse echoes={echoes} />}
</div>
);
}
);
PagerRefreshSingleton.displayName = 'PagerRefreshSingleton';

View file

@ -1,5 +1,6 @@
import { style } from '@vanilla-extract/css';
import { color } from 'folds';
import { color, toRem } from 'folds';
import { TABS_ROW_PX } from '../stream-header/geometry';
// Pager root. Sits inside the authed shell's row-flex slot
// (ClientLayout → Box grow=Yes), so `flex: 1 1 0` fills the slot
@ -11,10 +12,20 @@ import { color } from 'folds';
// listener calls preventDefault, and only after axis-resolve commits
// to "horizontal".
//
// `SurfaceVariant.Container` backdrop intentionally shows through
// (a) the inter-pane gap during a swipe — the gap colour the user
// asked for is "light blue same as the header", which IS this
// SurfaceVariant tone — and (b) any sub-pixel rounding seam at rest.
// `SurfaceVariant.Container` backdrop — THE single static light-blue
// canvas of the owner's composition model (his words, Jun 2026):
// «есть чисто статический бекграунд светлосиний (#181a20 в тёмной
// теме), растянут поверх всего экрана; в шапке статичные вкладки —
// просто надписи на этом фоне; ниже — тёмная (#0d0e11) шторка чатов
// со скруглением вверху; шторки листаются как картинки в галерее,
// между ними ничего — пустой прозрачный геп, виден этот бекграунд;
// оттянул шторку до низа — виден этот же бекграунд». So this one
// backdrop intentionally shows through (a) the inter-pane gap during
// a swipe, (b) the deep-pull void when the curtain is dragged far
// down, and (c) any sub-pixel seam at rest. DO NOT darken it «to
// match the curtain» — that was tried and rejected on device: a dark
// gap reads as «чёрные полосы» between sheets, and the deep-pull
// void merges with the curtain into one undifferentiated dark slab.
//
// `color: Background.OnContainer` mirrors what the route-level
// `<PageRoot>` would have set via its `ContainerColor({ variant:
@ -123,16 +134,137 @@ export const pagerStaticHeader = style({
// Horizontal strip carrying all three panes side-by-side. Width &
// transform are computed inline in the JSX (they depend on tabs.length
// and visualIdx + visualDragPx, and the gap math couples to them).
// and visualIdx + visualDragPx, and the gap math couples to them), as
// is `counterTx` — the negated strip X handed to the strip-hosted
// refresh singleton for its counter-translation (see
// `pagerRefreshSingleton` below for the full contract, including why
// the chrome must stay INSIDE the strip).
//
// `gap: PANE_GAP_PX` is what makes the inter-pane void visible during
// a swipe — the pagerRoot's SurfaceVariant.Container colour shows
// through the gap, matching the static header tone exactly.
// NO `will-change: transform` here — deliberately (Jun 2026). The strip
// is ALREADY composited by its always-on inline `translate3d(...)` (a 3D
// transform is a direct compositing reason), so will-change added
// nothing for promotion — but it DID set cc's raster-even-if-not-drawn
// on the strip's content (cc TransformNode::will_change_transform),
// forcing the WebView to keep tiles for the entire ~3.2-screen-wide
// strip, including off-screen panes. With all three curtains pulled to
// the refresh snap and the user shuttling between tabs, that standing
// cost blew the tile budget (489 «tile memory limits exceeded» warnings
// in one 14s device session) — dropped curtain tiles read as flicker
// and the rings «просвечивали» through. Without the flag, off-screen
// tiles demote and evict under pressure; the commit-slide transition
// still runs on the compositor because the layer exists either way.
export const strip = style({
display: 'flex',
flexDirection: 'row',
height: '100%',
willChange: 'transform',
});
// ── The pull-to-refresh chrome singleton's host ─────────────────────
//
// WHAT: the screen-static pull-to-refresh chrome (mascot card + radar
// rings), mounted ONCE as the strip's FIRST child
// (`PagerRefreshSingleton` in MobileTabsPager.tsx) and counter-
// translated against the strip's live translation. The strip's
// always-on inline `translate3d` makes it the containing block for
// absolute descendants, so `left: 0` is the STRIP origin — negating the
// strip's X lands the host at the screen origin for every active tab,
// with no per-pane offset needed.
//
// The counter-transform itself is INLINE (PagerRefreshSingleton.tsx):
// `translateX(counterTx) translateZ(0)` + the strip's transition string
// verbatim, both passed as props from MobileTabsPager so the two
// transforms are committed in the same React style flush and — during
// the 280ms commit-slide — both transition on the COMPOSITOR in
// lockstep. History (device-verified, Jun 2026): a registered
// `--vojo-strip-tx` @property transition was tried first; custom-
// property interpolation runs on the MAIN thread, and the `navigate()`
// route commit at slide start reliably froze it for the first frames of
// every swipe commit — the mascot rode the strip, then snapped back
// («дёргает в сторону свайпа»), and the per-frame main-thread re-raster
// pressured the tile budget under a double-dwell shuttle until curtain
// tiles dropped (rings «просвечивали» through). The translateZ(0)
// promotion is part of the fix: it moves the host's transform onto the
// compositor and stops the strip-layer re-raster; the layer is cheap —
// the host paints nothing, tiles materialise only for the card band
// (and the rings canvas is its own untiled TextureLayer — see
// RadarPulse.tsx).
//
// WHY INSIDE THE STRIP (the hoist postmortem — empirically established
// on device, Jun 2026): hosted OUTSIDE the strip (at the pager root)
// the WebView tile-memory budget blew at ~80 «tile memory limits
// exceeded» warnings/second while the rings animated — dropped tiles,
// curtain flicker, stuttering rings. Hosted inside: 0 warnings.
// In-strip hosting keeps the feature's promoted-layer set down to ONE
// rings canvas (an untiled TextureLayer — its ~7MB×2-3 buffers never
// enter the tile budget at all; see the RadarPulse.tsx postmortem),
// ONE video, and this host's empty container layer — instead of
// stacking un-cullable full-screen layers at the pager root. (Do NOT over-claim occlusion culling of the
// rings by the curtains — whole-layer contents-opaque is likely
// defeated by the curtain's rounded corners; the budget argument is
// parity with the empirically proven arrangement. See also the strip
// comment above: `will-change: transform` was removed from the strip
// because it forced raster-even-if-not-drawn across all ~3.2 screens of
// pane content and blew the budget when every curtain was parked at the
// refresh snap during a shuttle swipe.)
//
// PAINT ORDER: this host (absolute + transform ⇒ a z:auto stacking
// context) is the FIRST positioned z:auto participant of the strip's
// stacking context in tree order; every pane surface that must cover
// it is positioned-later-in-tree (the `pane` class below is
// position:relative; the horseshoe container is relative, its appBody
// is absolute with an always-on clip-path stacking context, the
// silhouette is absolute; the StreamHeader stage is relative) or
// positive-z (header z:1, curtain z:2). NOT plain «DOM order»: a
// non-positioned flex item would paint in the earlier in-flow phase
// BELOW this positioned host regardless of order. INVARIANT: any
// opaque paint inside a pane between backdrop and curtain must be
// positioned or a stacking context, or it will paint UNDER the rings.
//
// HARD RULES: never give this host a positive z-index (it would lift
// the chrome above the curtains), and never use `will-change` here
// (unlike plain translateZ it forces raster-even-if-not-drawn — the
// chrome would keep rastering behind the closed curtains).
//
// GEOMETRY: `top: 0` — NOT stagePulseLayer's `-safe-top` offset (that
// one is STAGE-relative; the stage starts at y = safe-top). This strip
// child's y-origin is already the screen top, since pagerRoot fills
// from y = 0. `stagePulseCanvas`'s safe-top-relative HEIGHT therefore
// yields the identical ring centre as in-stage, and the rings still
// spill into the status-bar zone.
//
// P1c DETERMINATION (recorded so it isn't re-litigated): the thin
// 0.26-opacity rings paint OVER the static tab glyphs. Putting the
// labels above the rings would require z-elevating the static header
// exactly while revealing — but the reveal flag is also true during
// every curtain drag, including the pin gesture, and an elevated
// header there paints tabs OVER the rising curtain (the documented
// pin-contract regression); a dwell-only elevation causes visible
// z-pops. Keep rings-over-labels.
export const pagerRefreshSingleton = style({
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: '100vw',
overflow: 'hidden',
pointerEvents: 'none',
});
// Positions the shared mascot card inside `pagerRefreshSingleton` at
// the exact screen Y the per-pane card occupied: below the safe-top
// inset plus the tabs row. The card itself (`stream-header/
// StreamHeader.css.ts::mascotCard`) brings its own height + centring +
// opaque backdrop. No promotion needed here: the HOST is the composited
// layer (see above) and repositions on the compositor; the card rasters
// once into the host's layer.
export const pagerMascotSlot = style({
position: 'absolute',
top: `calc(var(--vojo-safe-top, 0px) + ${toRem(TABS_ROW_PX)})`,
left: 0,
right: 0,
});
// Each pane is exactly one viewport wide. CRITICALLY `display: flex;
@ -151,7 +283,17 @@ export const strip = style({
// `paddingTop: var(--vojo-safe-top)`. The static header overlay at
// the pager root simply paints OVER the same screen zone, so the
// underlying geometry stays identical to non-pager mode.
//
// `position: relative` (z-index:auto — deliberately NO z, so no
// stacking context and no layer): makes each pane a tree-order
// participant of the strip's positioned paint phase, painting AFTER
// the DOM-first refresh singleton. This hardens the singleton-below-
// panes ordering against any future non-positioned stacking-context
// wrapper (opacity/filter/contain) inside a pane, which would
// otherwise drop the pane's whole subtree — curtain included — into
// the in-flow phase below the host. See `pagerRefreshSingleton`.
export const pane = style({
position: 'relative',
display: 'flex',
flexDirection: 'row',
flexShrink: 0,

View file

@ -41,9 +41,13 @@ type Args = {
//
// Conflict resolution with other gestures sharing the same surface
// (curtain, MobileSettingsHorseshoe, ChannelsWorkspaceHorseshoe) is
// cooperative: every gesture-owner resolves axis at the same dead-
// zone (12px) and bails when its own axis doesn't dominate. The pager
// wins horizontal; the others win vertical.
// cooperative: every gesture-owner resolves axis inside a dead-zone
// and bails when its own axis doesn't dominate. The pager and the
// horseshoes resolve at 12px; the CURTAIN hooks resolve at 10px
// (`stream-header/geometry.ts::DIRECTION_DEAD_ZONE_PX`) — i.e. in the
// 1012px window the curtain decides first, by design (its vertical
// pull is the most frequent gesture and should feel the most eager).
// The pager wins horizontal; the others win vertical.
export function useMobileTabsPagerGesture({
rootRef,
activeIdx,

View file

@ -0,0 +1,241 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { PULSE_EDGE_PX, PULSE_SPAN_PX } from './geometry';
import * as css from './StreamHeader.css';
// ─── The approved ring field, byte-for-byte ──────────────────────────
// 10 rings; each lives one 4s ease-out pass: scale 0.05 → 1 of a
// 760²-box circle (r 380, stroke 2 SCALING with the ring — 0.1px when
// tiny, a crisp 2px at full size), opacity 0 → 0.26 (at 10%) → 0.13
// (at 65%) → 0, spawn stagger 0.4s. DO NOT RETUNE — see geometry.ts::
// PULSE_SPAN_PX for why the box size is also load-bearing.
const LOOP_MS = 4000;
const RING_COUNT = 10;
const STAGGER_MS = 400;
const RING_RADIUS_PX = PULSE_SPAN_PX / 2;
const STROKE_PX = 2;
const STROKE_STYLE = 'rgb(248, 80, 160)';
const MIN_SCALE = 0.05;
// Lifetime of a one-shot echo ring — the 4s pulse plus a small buffer
// before its entry is pruned from React state.
const ECHO_LIFETIME_MS = 4100;
// CSS `ease-out` = cubic-bezier(0, 0, 0.58, 1). x(u) is strictly
// monotonic on [0, 1], so a fixed-depth bisection inverts time → u and
// the eased progress is y(u). 20 iterations ≈ 1e-6 — and a frame does
// ~2 solves per ring, noise next to the raster cost of the frame.
function easeOut(t: number): number {
if (t <= 0) return 0;
if (t >= 1) return 1;
let lo = 0;
let hi = 1;
for (let i = 0; i < 20; i += 1) {
const u = (lo + hi) / 2;
const x = 3 * (1 - u) * u * u * 0.58 + u * u * u;
if (x < t) lo = u;
else hi = u;
}
const u = (lo + hi) / 2;
return 3 * (1 - u) * u * u + u * u * u;
}
// The approved opacity keyframes. Per CSS keyframe semantics the
// timing function applies PER SEGMENT (between consecutive keyframes
// that specify the property), so each span below gets its own ease-out
// of the local progress — exactly what the old `ringKeyframes` did.
const OPACITY_STOPS: ReadonlyArray<readonly [number, number]> = [
[0, 0],
[0.1, 0.26],
[0.65, 0.13],
[1, 0],
];
function ringAlpha(phase: number): number {
for (let i = 1; i < OPACITY_STOPS.length; i += 1) {
const [k1, a1] = OPACITY_STOPS[i];
if (phase <= k1) {
const [k0, a0] = OPACITY_STOPS[i - 1];
return a0 + (a1 - a0) * easeOut((phase - k0) / (k1 - k0));
}
}
return 0;
}
// One-shot echo ring spawned by a mascot tap. `startedAt` is a
// performance.now() timestamp — the same timebase as the rAF clock the
// field draws from, so an echo's phase is absolute: a host remount
// mid-flight RESUMES the echo where it was instead of replaying it
// (the old CSS-animation circles restarted from 0 on remount; resuming
// is the closer match to «one pass per tap»).
export type EchoPulse = { id: number; startedAt: number };
// Tap-to-poke echo state. Each `spawnEcho` call appends a one-shot
// ring; a timer prunes it once its pulse has faded. Hosted by the
// component that renders the `RadarPulse` (StreamHeader, or
// PagerRefreshSingleton in pager mode) so the echoes live exactly as
// long as their host's reveal window.
export function useEchoes(): { echoes: EchoPulse[]; spawnEcho: () => void } {
const [echoes, setEchoes] = useState<EchoPulse[]>([]);
const echoIdRef = useRef(0);
const spawnEcho = useCallback(() => {
const id = echoIdRef.current;
echoIdRef.current += 1;
setEchoes((prev) => [...prev, { id, startedAt: performance.now() }]);
window.setTimeout(() => setEchoes((prev) => prev.filter((x) => x.id !== id)), ECHO_LIFETIME_MS);
}, []);
return { echoes, spawnEcho };
}
type RadarPulseProps = {
// One-shot echo ring ids + spawn timestamps (see `useEchoes`). Each
// renders a single non-looping pulse identical to an ambient ring.
echoes: EchoPulse[];
};
// The radar-pulse ring field — pink concentric rings rippling out from
// the mascot centre. ONE transparent 2D <canvas>, fully redrawn every
// rAF (10 stroked arcs + echoes).
//
// WHY A CANVAS (tile-memory postmortem, Jun 2026 — device-measured and
// verified against Chromium sources at tags 120/130/137):
//
// The previous live CSS-animated SVG circles blew the WebView tile
// budget. A running compositor animation is a DIRECT compositing
// reason on the animated element itself — SVG children included
// (`compositing_reason_finder.cc::DirectReasonsForSVGChildPaint
// Properties`, kActiveTransformAnimation; opacity keyframes alike via
// kActiveOpacityAnimation; on by default since M89). So EACH <circle>
// got its own cc layer, rastered at the animation's peak to-screen
// scale (scale 1 × dpr 3 ⇒ 2286² device px each; `picture_layer_impl
// .cc::AdjustRasterScaleForTransformAnimation` — and the viewport-area
// raster clamp sits at max(vw,vh)² = 2340², which 2286² legally fits
// under). cc's solid-colour analysis cannot cull a single tile of such
// a layer: the analyzer bails on ANY DrawOval/DrawPath op it sees
// (`solid_color_analyzer.cc`), and the big ring's op bbox covers the
// whole box — so all on-screen tiles (544×608×4B GPU-raster tiles on
// this device, NOT 256²) held real textures: ≈1011 MB × 10 rings ≈
// +110 MB against WebView's budget of exactly 20 × 4B × viewport px
// rounded up to 5 MiB ≈ 204.5 MB (`browser_view_renderer.cc`).
// Shuttle-swiping with 13 curtains parked at the refresh snap then
// evicted CURTAIN tiles first («tile memory limits exceeded» ×100600
// per 14s session; curtains flashed backdrop, rings showed through).
//
// The canvas collapses all of that to ONE cc TextureLayer: its buffers
// (~7 MB × 23 in flight, copy-on-write SharedImages) are client
// resources that NEVER enter the tile-manager budget, and the GL total
// is ~57× below the SVG field. Researched-and-rejected alternatives:
// animating `r`/`stroke-width`/`stroke-opacity` (keeps one layer but
// re-rasters the field's ~20 tiles every frame on the pending tree —
// 3753 MB while animating); inflating the box to trip cc's raster
// clamp (mathematically can't help — the clamp scales DOWN onto the
// 2340² threshold, ≥ today's raster, and any will-change ancestor
// restores native scale outright); `animation-play-state: paused` and
// pre-baked video (owner-vetoed on device). The svg root's own
// translateZ(0) was always irrelevant — promotion was per-circle.
//
// CONTRACT — every rule below guards a verified failure mode:
// • context options exactly `{alpha: true, willReadFrequently:
// false}`; NEVER add `desynchronized: true` — on WebView it swaps
// the TextureLayer for a SurfaceLayerBridge whose surface may
// never activate (the historical «opaque white canvas over
// everything»), and WebView has no low-latency overlay path anyway
// (kWebViewSurfaceControl off).
// • never call getImageData/putImageData/toDataURL on this canvas —
// two readbacks silently demote it to CPU raster permanently.
// • the backing store is device-pixel exact (css size × dpr), set
// only from the ResizeObserver — a CSS-px backing would GPU-
// upscale 3× and visibly blur the 2px strokes.
// • the rAF loop lives on the main thread: a long route-commit task
// freezes the field for the task's duration, then it catches up to
// wall clock (rings advance ~2.5% of their loop per 100ms, so
// commit-length stalls read as continuous; the multi-second
// freezes the owner vetoed came from `animation-play-state:
// paused`, a different class). If stalls ever grow past ~150ms,
// the escalation path is moving THIS SAME draw loop into a worker
// via OffscreenCanvas — not back to DOM animations.
//
// Geometry contract lives on `stagePulseCanvas` (StreamHeader.css.ts):
// the canvas spans the host's width from the screen top down to the
// field's bottom edge, and the draw centre is recovered from the box
// itself — cx = width/2, cy = height (PULSE_SPAN_PX/2 +
// PULSE_EDGE_PX) ⇒ exactly safe-top + PULSE_CENTER_Y_PX, the same
// centre the old svg box had in both hosts.
export function RadarPulse({ echoes }: RadarPulseProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
// Latest echoes for the draw loop without restarting it — the loop
// effect deliberately has no deps.
const echoesRef = useRef<EchoPulse[]>(echoes);
echoesRef.current = echoes;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return undefined;
const ctx = canvas.getContext('2d', { alpha: true, willReadFrequently: false });
if (!ctx) return undefined;
const dpr = window.devicePixelRatio || 1;
let cssWidth = 0;
let cssHeight = 0;
const resize = () => {
const { clientWidth, clientHeight } = canvas;
if (!clientWidth || !clientHeight) return;
cssWidth = clientWidth;
cssHeight = clientHeight;
canvas.width = Math.round(clientWidth * dpr);
canvas.height = Math.round(clientHeight * dpr);
// Resizing resets all context state — restore the css-px
// coordinate space and the stroke colour.
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.strokeStyle = STROKE_STYLE;
};
// Sized once synchronously (the host mounts this visible, never
// display:none) + re-sized on rotation/viewport changes.
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
const drawRing = (cx: number, cy: number, phase: number) => {
const s = MIN_SCALE + (1 - MIN_SCALE) * easeOut(phase);
ctx.globalAlpha = ringAlpha(phase);
ctx.lineWidth = STROKE_PX * s;
ctx.beginPath();
ctx.arc(cx, cy, RING_RADIUS_PX * s, 0, Math.PI * 2);
// A full-sweep arc() is an OPEN contour in Blink (two 180° arcs
// with coincident butt caps) — closePath() joins it so the cap
// edges can't double-blend into a seam at the start angle.
ctx.closePath();
ctx.stroke();
};
let raf = 0;
let mountTs: number | null = null;
const frame = (now: number) => {
raf = window.requestAnimationFrame(frame);
if (!cssWidth || !cssHeight) return;
// Ambient ring k starts STAGGER_MS·k after the first frame, then
// loops — the same field the old per-circle animation-delays
// produced on mount.
if (mountTs === null) mountTs = now;
const t = now - mountTs;
ctx.clearRect(0, 0, cssWidth, cssHeight);
const cx = cssWidth / 2;
const cy = cssHeight - (RING_RADIUS_PX + PULSE_EDGE_PX);
for (let k = 0; k < RING_COUNT; k += 1) {
const local = t - k * STAGGER_MS;
if (local >= 0) drawRing(cx, cy, (local % LOOP_MS) / LOOP_MS);
}
echoesRef.current.forEach(({ startedAt }) => {
const local = now - startedAt;
if (local >= 0 && local < LOOP_MS) drawRing(cx, cy, local / LOOP_MS);
});
};
raf = window.requestAnimationFrame(frame);
return () => {
window.cancelAnimationFrame(raf);
observer.disconnect();
};
}, []);
return <canvas ref={canvasRef} className={css.stagePulseCanvas} aria-hidden="true" />;
}

View file

@ -41,6 +41,12 @@ type RefreshMascotProps = {
// caption — the animation is the whole affordance (Claude-mobile-app
// style).
//
// Two hosts: the in-stage header slot on NON-pager native surfaces, and
// the strip-hosted PagerRefreshSingleton in pager mode, where the card
// stays put while the panes slide and `onPoke` is omitted (the singleton
// is pointer-events-none; pokes arrive via the per-pane tap-catcher
// through `mobilePagerMascotApiAtom`).
//
// We render a plain `<video>` — NOT the auth `mascotSingleton`
// currentTime-preservation machinery, which solves a cold-boot remount
// problem this surface doesn't have; the two mascot surfaces stay
@ -80,9 +86,10 @@ export function RefreshMascot({ caption, revealing, videoRef, onPoke }: RefreshM
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
onClick={onPoke}
>
{/* The radar-pulse rings live at the header level (StreamHeader), not
here they break out of this «formal» card and ripple across the
whole light-blue chrome. This card holds just the mascot + caption. */}
{/* The radar-pulse rings live at the host level (StreamHeader's stage
layer, or the strip-hosted singleton), not here they break out of
this «formal» card and ripple across the whole chrome. This card
holds just the mascot + caption. */}
{showVideo ? (
<video
ref={videoRef}

View file

@ -1,4 +1,4 @@
import { keyframes, style } from '@vanilla-extract/css';
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { color, config, toRem } from 'folds';
import {
@ -8,6 +8,9 @@ import {
HANDLE_HEIGHT_PX,
MASCOT_CARD_PX,
MASCOT_VIDEO_PX,
PULSE_CENTER_Y_PX,
PULSE_EDGE_PX,
PULSE_SPAN_PX,
TABS_ROW_PX,
WEB_TABS_ROW_PX,
} from './geometry';
@ -159,7 +162,13 @@ export const curtain = style({
borderTopLeftRadius: toRem(CURTAIN_RADIUS_PX),
borderTopRightRadius: toRem(CURTAIN_RADIUS_PX),
transition: `top ${CURTAIN_SNAP_MS}ms ${CURTAIN_SNAP_EASING}`,
// Hint the compositor while the curtain is moving.
// NB: `top` is a LAYOUT property — its animation runs on the main
// thread regardless of this hint (will-change of a non-compositable
// property does not promote a layer). Kept as a mild
// style-invalidation hint; the curtain being layout-driven (not
// transform-driven) is itself deliberate — `bottomPinned` must stay
// glued to the viewport bottom while the top edge moves, which a
// translateY of the whole curtain would break.
willChange: 'top',
selectors: {
'[data-platform="web"] &': {
@ -300,6 +309,15 @@ export const segmentDot = recipe({
// height equals `MASCOT_CARD_PX`, which the curtain's refresh rest-top
// (`TABS_ROW_PX + MASCOT_CARD_PX`) lands exactly on — so the card edge
// sits at the curtain's rounded top with the content centred above it.
// Own opaque backdrop so the refresh reveal stays solid in pager mode,
// where the `header` bg collapses to transparent (the transparent
// mascot video would otherwise show the static pager header through it).
//
// Two hosts: the in-stage header slot on NON-pager native surfaces, and
// the strip-hosted PagerRefreshSingleton in pager mode
// (mobile-tabs-pager/style.css.ts::pagerMascotSlot positions it at the
// same screen Y), where it stays put while the panes slide. The opaque
// SurfaceVariant backdrop is correct in both.
export const mascotCard = style({
flexShrink: 0,
height: toRem(MASCOT_CARD_PX),
@ -308,32 +326,56 @@ export const mascotCard = style({
alignItems: 'center',
justifyContent: 'center',
gap: toRem(2),
// Own opaque backdrop so the refresh reveal stays solid in pager mode,
// where the `header` bg collapses to transparent (the transparent
// mascot video would otherwise show the static pager header through it).
backgroundColor: color.SurfaceVariant.Container,
});
// Invisible stand-in for the mascot card in PAGER mode. The visible
// mascot lives in the strip-hosted singleton (PagerRefreshSingleton),
// but that singleton is `pointer-events: none` behind the panes'
// hit-test stack, so the tap-to-poke easter egg would die on the pane's
// transparent header band. This catcher occupies the exact slot the
// in-stage card would (same flex position + height) and proxies taps to
// the singleton via `mobilePagerMascotApiAtom`. At the closed snap it's
// z-covered by the curtain exactly like the card, so it's only
// reachable while the refresh band is actually revealed.
export const mascotPokeCatcher = style({
flexShrink: 0,
height: toRem(MASCOT_CARD_PX),
});
// Radar-pulse overlay — pink concentric rings rippling across the light-blue
// surface behind the curtain. Rendered as ONE <svg> of 10 <circle>s, NOT 10
// CSS-animated <span>s: each animated span is promoted to its own GPU layer,
// and ten such layers blow Chromium's `cc` tile-memory budget on Android
// WebView ("tile memory limits exceeded, some content may not draw") → the
// curtain flickers (device logcat: that warning fired 572× only while the
// rings animated). Chrome does NOT split an SVG into per-element GPU layers,
// so all ten rings share ONE layer and the tile budget holds.
// surface behind the curtain. Rendered as ONE transparent 2D <canvas>
// (RadarPulse.tsx) — NOT CSS-animated DOM rings. History of this surface,
// each step device-measured: 10 animated <span>s blew the WebView tile
// budget (572 «tile memory limits exceeded» warnings); one <svg> of 10
// animated <circle>s did NOT fix it — a running transform/opacity animation
// is a direct compositing reason on each SVG CHILD, so every circle still
// got its own cc layer rastered at peak scale (~110 MB; full corrected
// root-cause in RadarPulse.tsx). The canvas is one untiled TextureLayer
// whose buffers never touch the tile budget at all.
//
// (A <canvas> also collapses to one layer and likewise zeroed the tile
// warning — but this WebView composites a full-screen, every-frame 2D canvas
// as an OPAQUE surface: solid white over the mascot. SVG paints in the normal
// tree like the old spans did, so it stays transparent.)
// (The historical «full-screen 2D canvas composites as an OPAQUE WHITE
// surface» incident that long banned canvases here is explained and
// avoided: that's the `desynchronized: true` / SurfaceLayerBridge path.
// A plain `{alpha: true}` canvas composites as a normal transparent
// TextureLayer in the page's paint order — see the context contract in
// RadarPulse.tsx.)
//
// This clip layer spans the visible surface — up into the safe-top (negative
// `top`) and down to the stage bottom — and clips the rings at the status-bar
// edge. `zIndex: 1` is over the header chrome but under the curtain
// (`zIndex: 2`), which occludes whatever's below its top edge. `pointerEvents:
// none` lets taps fall through. Rendered only while revealed + animated
// (StreamHeader `showPulse`).
// This layer spans the visible surface — up into the safe-top (negative
// `top`, so its origin is the SCREEN top like the pager singleton host's)
// and down to the stage bottom. `zIndex: 1` is over the header chrome but
// under the curtain (`zIndex: 2`), which occludes whatever's below its top
// edge. `pointerEvents: none` lets taps fall through. Rendered only while
// revealed + animated (StreamHeader `showPulse`).
//
// Rendered only on NON-pager native surfaces (StreamHeader gates
// `showPulse` on `!inPagerMode`); in pager mode the rings render inside
// the strip-hosted PagerRefreshSingleton instead — see
// mobile-tabs-pager/style.css.ts::pagerRefreshSingleton for the full
// hosting / tile-memory contract. NB the `-safe-top` top offset here is
// STAGE-relative (the stage starts at y = safe-top) and must NOT be
// reused for the singleton host, whose y-origin is already the screen
// top.
export const stagePulseLayer = style({
position: 'absolute',
top: 'calc(-1 * var(--vojo-safe-top, 0px))',
@ -345,62 +387,29 @@ export const stagePulseLayer = style({
zIndex: 1,
});
// One ring's scale + fade — the old span keyframes, byte-for-byte: scale
// 0.05→1 (the 2px stroke scales WITH it → 0.1px when tiny, a crisp 2px at
// full size — the approved look), opacity 0 → 0.26 → 0.13 → 0.
const ringKeyframes = keyframes({
'0%': { transform: 'scale(0.05)', opacity: 0 },
'10%': { opacity: 0.26 },
'65%': { opacity: 0.13 },
'100%': { transform: 'scale(1)', opacity: 0 },
});
// The 760² ring SVG, centred on ≈ the mascot centre (safe-top + tabs row +
// the card's top pad + half the video box). `translateZ(0)` keeps the rings'
// per-frame raster isolated to this one small layer rather than repainting
// the whole stage; `overflow: visible` so the full-scale stroke isn't clipped.
export const stagePulseSvg = style({
// The ring-field canvas. Spans the host's full width from the screen top
// down to the field's bottom edge: safe-top + PULSE_CENTER_Y_PX (ring
// centre) + PULSE_SPAN_PX/2 (largest ring) + PULSE_EDGE_PX (stroke + AA
// headroom). RadarPulse recovers the draw centre from the box itself —
// cx = width/2, cy = height (PULSE_SPAN_PX/2 + PULSE_EDGE_PX) — which
// lands at exactly safe-top + PULSE_CENTER_Y_PX from the screen top, the
// same centre the old 760² svg box had. Both hosting layers
// (`stagePulseLayer` here, the pager singleton host) start at the top of
// the SCREEN, so this one safe-top-relative height works in either.
// Rings wider than the screen simply draw past the canvas edges — the
// clipping the old svg needed its host's overflow:hidden for is now
// inherent to the backing store. Keep the canvas VIEWPORT-width (not
// 760px wide): the backing buffers are ~7 MB × 23 in flight at dpr 3,
// and width is the lever that keeps them there.
export const stagePulseCanvas = style({
position: 'absolute',
left: '50%',
top: `calc(var(--vojo-safe-top, 0px) + ${toRem(TABS_ROW_PX + 53)})`,
width: toRem(760),
height: toRem(760),
overflow: 'visible',
transform: 'translate(-50%, -50%) translateZ(0)',
});
// Each concentric ring. `transformBox: fill-box` + `transformOrigin: center`
// so `scale()` grows it about its own centre. NO `non-scaling-stroke` — the
// stroke scales with the circle, matching the approved span look.
export const stagePulseRing = style({
fill: 'none',
stroke: 'rgb(248, 80, 160)',
strokeWidth: 2,
transformBox: 'fill-box',
transformOrigin: 'center',
opacity: 0,
animationName: ringKeyframes,
animationDuration: '4s',
animationTimingFunction: 'ease-out',
animationIterationCount: 'infinite',
});
// One-shot "echo" ring spawned when the user taps the mascot — IDENTICAL to an
// ambient ring (same `ringKeyframes`, 4s, same 0.26 peak), just a single pulse
// instead of looping. StreamHeader adds one <circle> per tap to `echoes` and
// drops it from the DOM by a timer once it's faded.
export const stagePulseEcho = style({
fill: 'none',
stroke: 'rgb(248, 80, 160)',
strokeWidth: 2,
transformBox: 'fill-box',
transformOrigin: 'center',
opacity: 0,
animationName: ringKeyframes,
animationDuration: '4s',
animationTimingFunction: 'ease-out',
animationIterationCount: 1,
animationFillMode: 'forwards',
top: 0,
left: 0,
width: '100%',
height: `calc(var(--vojo-safe-top, 0px) + ${toRem(
PULSE_CENTER_Y_PX + PULSE_SPAN_PX / 2 + PULSE_EDGE_PX
)})`,
pointerEvents: 'none',
});
// The looping mascot video (or its still poster under reduced-motion),

View file

@ -4,6 +4,7 @@ import React, {
TransitionEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@ -18,10 +19,13 @@ import { isNativePlatform } from '../../utils/capacitor';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useBotPresets } from '../../features/bots/catalog';
import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext';
import type { MobilePagerTab } from '../mobile-tabs-pager/MobilePagerPaneContext';
import {
MobilePagerCurtainControls,
StreamHeaderPrimaryAction,
curtainRefreshRevealByTabAtom,
mobilePagerCurtainAtom,
mobilePagerMascotApiAtom,
} from '../../state/mobilePagerHeader';
import { REFRESH_MAX_MS, REFRESH_MIN_MS, TABS_ROW_PX, WEB_TABS_ROW_PX } from './geometry';
import { settingsSheetAtom } from '../../state/settingsSheet';
@ -29,12 +33,16 @@ import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
import * as css from './StreamHeader.css';
import { Segment } from './Segment';
import { RefreshMascot } from './RefreshMascot';
import { RadarPulse, useEchoes } from './RadarPulse';
import { isFormSnap, isRefreshSnap, snapTopPx, useCurtainState } from './useCurtainState';
import { useCurtainHandleGesture } from './useCurtainHandleGesture';
import { useCurtainBodyGesture } from './useCurtainBodyGesture';
import { InlineRoomSearch } from './forms/InlineRoomSearch';
const INLINE_FORM_ID = 'stream-header-inline-form';
// Exported so the pager's static header can `aria-controls` the same
// form region (MobileTabsPagerHeader imports this instead of keeping a
// hand-synced copy of the literal).
export const INLINE_FORM_ID = 'stream-header-inline-form';
type StreamHeaderProps = {
// Scroll viewport that hosts the chat list under the curtain. The
@ -63,7 +71,11 @@ type StreamHeaderProps = {
// passes its own key; the Channels landing CTA and workspace
// listing share `"channels"` so pin survives the toggle between
// empty state and a chosen workspace.
pinKey: string;
//
// Typed as the pager tab name: the pin/reveal maps keyed by this are
// read back by MobileTabsPager(+Header) via the pager's own tab
// union, so the names must coincide — compiler-enforced.
pinKey: MobilePagerTab;
// Optional contextual create action (a Plus button in the tabs row).
// When omitted (Direct) the header shows no Plus at all — starting a chat
// is folded into search, which finds people via the homeserver user
@ -207,20 +219,33 @@ export function StreamHeader({
});
const isActive = isFormSnap(curtain.snap);
const openSearch = useCallback(() => curtain.open(), [curtain]);
const { close } = curtain;
// Destructured because these callbacks are individually STABLE
// (useCallback inside useCurtainState) while the `curtain` object
// changes identity on every drag frame (`liveDragPx` is in its memo
// deps). Depending on `curtain` itself here used to rebuild
// `pagerControls` — and therefore rewrite `mobilePagerCurtainAtom`
// (cleanup null + setup) — on every touchmove of a curtain drag,
// re-rendering the static pager header at input frequency. The
// memoisation only works when these flow through stable references.
const { open: openSearch, close, acknowledgeClosed } = curtain;
// Radar-pulse overlay gate. Native-only (the gesture is), silent under
// reduced-motion, and shown whenever the refresh card is revealed — at
// the `refresh` snap OR while a drag is in flight (`isDragging`).
// Refresh-reveal gate — the mascot card is on screen (or being pulled
// into view): the curtain rests at the `refresh` snap OR a drag is in
// flight (`isDragging`).
//
// We gate on `isDragging` (a stable boolean held true for the whole drag)
// NOT on `liveDragPx > 0`: a held finger micro-jitters around the drag
// origin, so `liveDragPx` dithers across 0 every frame. With the old
// `liveDragPx > 0` term that flipped `showPulse` true/false each frame,
// `liveDragPx > 0` term that flipped the pulse true/false each frame,
// remounting the pulse layer (and the mascot video) → the rings restarted
// over and over ("circle spam") and the curtain rattled. `isDragging`
// doesn't flip on the drag delta's sign, so the layer stays mounted.
const revealing = isRefreshSnap(curtain.snap) || curtain.isDragging;
// Radar-pulse overlay gate for the IN-STAGE layer. Native-only (the
// gesture is), silent under reduced-motion — and NON-pager only: in
// pager mode the rings live in the strip-hosted PagerRefreshSingleton,
// fed by the reveal map published below.
const reducedMotion = useMemo(
() =>
typeof window !== 'undefined' &&
@ -228,22 +253,54 @@ export function StreamHeader({
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
[]
);
const showPulse =
isNativePlatform() && !reducedMotion && (isRefreshSnap(curtain.snap) || curtain.isDragging);
const showPulse = isNativePlatform() && !reducedMotion && !inPagerMode && revealing;
// Tap-to-poke echo rings. Tapping the mascot (RefreshMascot `onPoke`) spawns
// a one-shot ring (`stagePulseEcho`) on top of the ambient radar pulse —
// same look + speed as an ambient ring, just one pass; each is dropped from
// the DOM by a timer once its 4s pulse has faded. They only paint while the
// pulse layer is mounted (`showPulse`).
const [echoes, setEchoes] = useState<number[]>([]);
const echoIdRef = useRef(0);
const spawnEcho = useCallback(() => {
const id = echoIdRef.current;
echoIdRef.current += 1;
setEchoes((prev) => [...prev, id]);
window.setTimeout(() => setEchoes((prev) => prev.filter((x) => x !== id)), 4100);
}, []);
// Tap-to-poke echo rings (non-pager path). Tapping the mascot
// (RefreshMascot `onPoke`) spawns a one-shot ring on top of the ambient
// radar pulse — same look + speed as an ambient ring, just one pass
// (drawn by the RadarPulse canvas from its spawn timestamp). They only
// paint while the pulse layer is mounted (`showPulse`). In pager mode
// the echo state lives in `PagerRefreshSingleton` instead, and the
// invisible poke catcher below proxies taps to it through
// `mobilePagerMascotApiAtom`.
const { echoes, spawnEcho } = useEchoes();
// Imperative channel published by the pager's refresh singleton: the
// live dance <video> for the loop-boundary auto-close, and the echo
// spawner the invisible poke catcher proxies taps into (the singleton
// is pointer-events:none behind the panes and can never receive the
// tap itself).
const mascotApi = useAtomValue(mobilePagerMascotApiAtom);
const onPokeCatcher = useCallback(() => mascotApi?.spawnEcho(), [mascotApi]);
// Publish this pane's reveal state into the per-tab map (keyed by
// `pinKey` — each pane owns its key, writers never race). The
// strip-hosted PagerRefreshSingleton ORs the map
// (`mobilePagerAnyRevealAtom`) and shows the one screen-static
// mascot+rings while ANY tab reveals — an inactive pane resting at
// `refresh` through its auto-close dwell keeps the shared mascot
// alive so a swipe-back mid-refresh is seamless.
//
// The `!activeForm` guard: a form-close drag has `isDragging` true
// but no band chrome on screen — and the singleton paints behind the
// panes' content, where the form band has no opaque backdrop in pager
// mode.
//
// useLayoutEffect so the atom write + singleton render flush in the
// same pre-paint commit as the first band-exposing frame at
// drag-start. Compare-and-skip keeps subscribers quiet when nothing
// flips.
const setRefreshRevealByTab = useSetAtom(curtainRefreshRevealByTabAtom);
const refreshRevealing = revealing && !curtain.activeForm;
useLayoutEffect(() => {
if (!inPagerMode) return undefined;
setRefreshRevealByTab((prev) =>
!!prev[pinKey] === refreshRevealing ? prev : { ...prev, [pinKey]: refreshRevealing }
);
return () => {
setRefreshRevealByTab((prev) => (prev[pinKey] ? { ...prev, [pinKey]: false } : prev));
};
}, [inPagerMode, pinKey, refreshRevealing, setRefreshRevealByTab]);
// Pull-to-refresh side-effect. When the curtain commits to the
// `refresh` snap, keep the mascot dancing for at least REFRESH_MIN_MS
@ -322,7 +379,11 @@ export function StreamHeader({
const refreshDragging = curtain.isDragging;
useEffect(() => {
if (!(refreshing && refreshReady && !refreshDragging)) return undefined;
const video = mascotVideoRef.current;
// In pager mode the dance video lives in the strip-hosted singleton,
// reachable through the api atom (null while hidden / poster — close
// immediately, matching the non-pager null-ref semantics); the local
// ref only hosts a video on non-pager native surfaces.
const video = inPagerMode ? mascotApi?.getVideo() ?? null : mascotVideoRef.current;
if (!video || video.paused || !Number.isFinite(video.duration) || video.duration <= 0) {
close();
return undefined;
@ -334,20 +395,24 @@ export function StreamHeader({
}
const timer = window.setTimeout(close, remainingMs);
return () => window.clearTimeout(timer);
}, [refreshing, refreshReady, refreshDragging, close]);
}, [refreshing, refreshReady, refreshDragging, close, inPagerMode, mascotApi]);
// Memoised controls object so the cleanup's identity check (atom
// compare-and-clear) is meaningful — without useMemo a fresh object
// would be created on every render and the cleanup of an earlier
// render would never match the atom's current contents.
// render would never match the atom's current contents. Rewrites only
// on real open/close/acknowledge edges — never per drag frame; see
// the `formMounted` comment in mobilePagerHeader.ts.
const formMounted = curtain.activeForm !== null;
const pagerControls = useMemo<MobilePagerCurtainControls>(
() => ({
openSearch,
closeForm: close,
isFormActive: isActive,
formMounted,
primaryAction: primaryAction ?? null,
}),
[openSearch, close, isActive, primaryAction]
[openSearch, close, isActive, formMounted, primaryAction]
);
const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom);
@ -395,9 +460,9 @@ export function StreamHeader({
(evt: TransitionEvent<HTMLDivElement>) => {
if (evt.target !== evt.currentTarget) return;
if (evt.propertyName !== 'top') return;
curtain.acknowledgeClosed();
acknowledgeClosed();
},
[curtain]
[acknowledgeClosed]
);
// On-screen keyboard detection via VisualViewport API. Global
@ -453,6 +518,28 @@ export function StreamHeader({
};
}, []);
// Dancing-mascot pull-to-refresh slot below the tabs row whenever no
// form is active. The curtain z-occludes it at `closed`; drag-down
// reveals it at the `refresh` snap.
//
// In pager mode the VISIBLE mascot is hoisted to the strip-hosted
// singleton (it must not ride this pane), so the slot holds only an
// invisible tap-catcher of the same geometry: the singleton is
// pointer-events-none, and without the catcher the tap-to-poke easter
// egg would die on the pane's transparent band. Non-pager native
// surfaces keep the in-stage card.
const refreshSlot = inPagerMode ? (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
<div className={css.mascotPokeCatcher} onClick={onPokeCatcher} aria-hidden="true" />
) : (
<RefreshMascot
caption={t('Direct.refreshing')}
revealing={revealing}
videoRef={mascotVideoRef}
onPoke={spawnEcho}
/>
);
return (
<div className={css.stage} data-platform={isNativePlatform() ? undefined : 'web'}>
<header className={css.header}>
@ -578,48 +665,21 @@ export function StreamHeader({
</div>
</div>
) : (
// Dancing-mascot pull-to-refresh card. Rendered in the header
// below the tabs row; the curtain z-occludes it at `closed` and
// the drag-down reveals it at the `refresh` snap. The Search /
// Channels-create chips that used to live here are gone — search
// is reached via the tabs-row icon, Channels-create via its
// tabs-row Plus.
<RefreshMascot
caption={t('Direct.refreshing')}
revealing={isRefreshSnap(curtain.snap) || curtain.isDragging}
videoRef={mascotVideoRef}
onPoke={spawnEcho}
/>
refreshSlot
)}
</header>
{/* Radar-pulse overlay
{/* Radar-pulse overlay (non-pager native surfaces only)
Concentric pink rings rippling across the light-blue surface behind
the curtain (z:1, the curtain at z:2 occludes whatever's below its
top edge). ONE <svg> of 10 <circle>s rather than 10 animated <span>s:
10 animated spans each become their own GPU layer and blow Chromium's
`cc` tile-memory budget on Android WebView ("some content may not
draw") flicker. Chrome doesn't split an SVG into per-element layers,
so the rings share one layer. `pointerEvents: none` so taps fall
through to the tabs. See `stagePulseLayer`. */}
top edge). `pointerEvents: none` so taps fall through to the tabs.
See `RadarPulse` for the single-canvas rationale (the tile-memory
postmortem) and `stagePulseLayer` for the geometry. In pager mode
`showPulse` is always false the rings render in the strip-hosted
PagerRefreshSingleton instead. */}
{showPulse && (
<div className={css.stagePulseLayer}>
<svg className={css.stagePulseSvg} viewBox="0 0 760 760" aria-hidden="true">
{[0, 0.4, 0.8, 1.2, 1.6, 2.0, 2.4, 2.8, 3.2, 3.6].map((delay) => (
<circle
key={delay}
className={css.stagePulseRing}
cx={380}
cy={380}
r={380}
style={{ animationDelay: `${delay}s` }}
/>
))}
{/* One-shot echo rings, one per mascot tap (see `spawnEcho`). */}
{echoes.map((id) => (
<circle key={`echo-${id}`} className={css.stagePulseEcho} cx={380} cy={380} r={380} />
))}
</svg>
<RadarPulse echoes={echoes} />
</div>
)}

View file

@ -21,17 +21,21 @@
// results clip flush at the curtain's top edge; the
// form's own height already reaches the curtain)
//
// Pinned visual contract: at `pinned` the curtain's top edge lands at
// Pinned visual contract: at `pinned` the curtain's top edge RESTS 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)
// NOT REST inside 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.
// visually taller). This is a REST-state invariant: every commit
// destination (pinned = 0) honours it, while a live `closed-free` /
// `close-refresh` drag intentionally has NO upper clamp and may
// overshoot into the safe-top under the finger (the «drag up
// off-screen freely» feel) — the release always settles back to a
// legal snap. We deliberately do not add any padding inside the
// curtain to enforce the second half.
// ────────────────────────────────────────────────────────────────────
// Tabs row height. Always visible above the curtain.
@ -73,6 +77,50 @@ export const WEB_TABS_ROW_PX = 54;
export const MASCOT_VIDEO_PX = 96;
export const MASCOT_CARD_PX = 124;
// Radar-pulse ring field — the square box (px) the concentric rings
// expand inside, centred on the mascot. 760 is the APPROVED look —
// byte-for-byte the original ring field — and is load-bearing twice
// over. DO NOT GROW IT:
//
// (1) Speed/feel: a ring scales 0.05 → 1 over a fixed 4s, so the box
// size IS the travel speed. An attempt to widen the sweep to
// 1600 made the rings (and especially the tap-echo rings)
// visibly «вылетать» ~2× faster than the approved pace —
// user-rejected on device.
// (2) Memory: the field is one transparent 2D canvas (RadarPulse),
// and the box's bottom edge sets the canvas height — the GPU
// buffers (~7 MB × 23 at dpr 3) scale with it. The WebView
// tile-memory budget around this feature has NO headroom — an
// attempted hoist of the ring field out of the strip blew it
// even at 760² (see the `pagerRefreshSingleton` postmortem in
// mobile-tabs-pager/style.css.ts); a bigger box only digs the
// hole deeper.
//
// «Rings across the whole screen / mascot doesn't ride the tab» is
// achieved by the HOST instead: in pager mode the chrome renders ONCE
// in a strip-hosted singleton (PagerRefreshSingleton) counter-
// translated against the strip's live translation (inline `counterTx`
// prop, compositor-transitioned in lockstep with the strip), so it
// stays screen-static during tab swipes and the rings spill across the
// inter-pane seam — while the field keeps the approved size and pace,
// and stays inside the strip where the tile budget is proven to hold.
export const PULSE_SPAN_PX = 760;
// Vertical centre of the radar pulse, measured from the TOP OF THE
// SCREEN minus the safe-top inset — i.e. the canvas height formula adds
// `var(--vojo-safe-top)` itself. = tabs row + the mascot card's top
// pad + half the video box, so the rings emanate from the mascot's
// centre. Encoded in `stagePulseCanvas`'s height (StreamHeader.css.ts);
// RadarPulse recovers the draw centre as
// height (PULSE_SPAN_PX/2 + PULSE_EDGE_PX). Both hosts' layers start
// at the screen top, so one formula serves either.
export const PULSE_CENTER_Y_PX = TABS_ROW_PX + 53;
// Canvas headroom (px) below the largest ring's outer edge: half the
// full-scale 2px stroke plus 1px of anti-aliasing, so the ring never
// clips at the canvas's bottom edge.
export const PULSE_EDGE_PX = 2;
// Initial estimate for the search form's outer height. The actual
// height is measured at runtime via ResizeObserver and adapts to the
// available viewport so the form never overflows the chats card.

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import { curtainPinnedByTabAtom } from '../../state/mobilePagerHeader';
import type { MobilePagerTab } from '../mobile-tabs-pager/MobilePagerPaneContext';
import { CURTAIN_SNAP_MS, MASCOT_CARD_PX, SEARCH_FORM_BASE_PX, TABS_ROW_PX } from './geometry';
// Discrete snap stops for the curtain. The curtain's resting `top`
@ -100,7 +101,11 @@ export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
}
}
export function useCurtainState(pinKey: string): CurtainState {
// `pinKey` is typed as the pager tab name (not a free-form string):
// the pin and reveal maps it keys into are read back by the pager and
// its static header via `[activeTab]` / `[tab]`, so the coupling is a
// hard contract, compiler-enforced since Jun 2026.
export function useCurtainState(pinKey: MobilePagerTab): CurtainState {
const [snap, setSnap] = useState<CurtainSnap>('closed');
const [activeForm, setActiveForm] = useState<ActiveForm>(null);
const [formHeightPx, setFormHeightPx] = useState<number | null>(null);

View file

@ -33,12 +33,13 @@ export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
// 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.
// Note: the curtain never RESTS inside the safe-top zone — every snap
// destination floors at `top: 0` of the stage (= `y = safe-top` in
// viewport), preserving the system-tray strip. (A live drag may
// transiently overshoot above it under the finger — the free-range
// gestures are unclamped by design — but the release always settles
// back to a legal snap.) See the «pinned visual contract» comment in
// `components/stream-header/geometry.ts` for the full invariant.
export const container = style({
position: 'relative',
display: 'flex',

View file

@ -359,6 +359,16 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
setDrag(null);
};
// System cancel (incoming call, notification shade, app switch) must
// NEVER commit an open/close — mirror the never-commit-on-cancel
// contract every other gesture driver in the app upholds (curtain
// handle/body, pager, swipe-back). Drop the drag; the silhouette
// springs back to its resting state.
const applyCancel = () => {
if (!dragRef.current) return;
setDrag(null);
};
const targetIsDragOrigin = (target: EventTarget | null): boolean => {
if (!target || !(target instanceof Element)) return false;
return target.closest(DRAG_ORIGIN_SELECTOR) !== null;
@ -397,6 +407,12 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
const onTouchMove = (e: TouchEvent) => {
const d = dragRef.current;
if (!d || d.inputType !== 'touch') return;
// Second finger mid-gesture — abort without committing, mirroring
// the curtain/pager drivers.
if (e.touches.length !== 1) {
applyCancel();
return;
}
applyMove(e.touches[0].clientX, e.touches[0].clientY, e);
};
const onTouchEnd = () => {
@ -404,6 +420,11 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
if (!d || d.inputType !== 'touch') return;
applyEnd();
};
const onTouchCancel = () => {
const d = dragRef.current;
if (!d || d.inputType !== 'touch') return;
applyCancel();
};
// === Mouse / pen path ===
const onDocPointerDown = (e: PointerEvent) => {
@ -449,15 +470,21 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
if (!d || d.inputType !== 'pointer') return;
applyEnd();
};
const onDocPointerCancel = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
const d = dragRef.current;
if (!d || d.inputType !== 'pointer') return;
applyCancel();
};
document.addEventListener('touchstart', onDocTouchStart, { passive: true });
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd, { passive: true });
document.addEventListener('touchcancel', onTouchEnd, { passive: true });
document.addEventListener('touchcancel', onTouchCancel, { passive: true });
document.addEventListener('pointerdown', onDocPointerDown);
document.addEventListener('pointermove', onDocPointerMove, { passive: false });
document.addEventListener('pointerup', onDocPointerEnd, { passive: true });
document.addEventListener('pointercancel', onDocPointerEnd, { passive: true });
document.addEventListener('pointercancel', onDocPointerCancel, { passive: true });
if (handleEl) {
handleEl.addEventListener('touchstart', onHandleTouchStart, { passive: true });
handleEl.addEventListener('pointerdown', onHandlePointerDown);
@ -467,11 +494,11 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
document.removeEventListener('touchstart', onDocTouchStart);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
document.removeEventListener('touchcancel', onTouchEnd);
document.removeEventListener('touchcancel', onTouchCancel);
document.removeEventListener('pointerdown', onDocPointerDown);
document.removeEventListener('pointermove', onDocPointerMove);
document.removeEventListener('pointerup', onDocPointerEnd);
document.removeEventListener('pointercancel', onDocPointerEnd);
document.removeEventListener('pointercancel', onDocPointerCancel);
if (handleEl) {
handleEl.removeEventListener('touchstart', onHandleTouchStart);
handleEl.removeEventListener('pointerdown', onHandlePointerDown);
@ -555,6 +582,15 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
<div
className={css.appBody}
style={{
// Always emitted, even at rest where it computes to
// `inset(0 …)` — see the `appBodyClipPath` comment above for
// why (interpolating from `undefined` would snap). Safe to
// keep always-on: the pull-to-refresh chrome is hosted in the
// strip-level singleton OUTSIDE this subtree, so the resting
// self-bounds clip slices nothing. (Bonus: a non-none
// clip-path makes appBody a permanent stacking context, which
// the singleton's paint-order contract counts on — see
// mobile-tabs-pager/style.css.ts::pagerRefreshSingleton.)
clipPath: appBodyClipPath,
transition: appBodyTransition,
overscrollBehaviorY: 'contain',

View file

@ -294,6 +294,16 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
setDrag(null);
};
// System cancel (incoming call, notification shade, app switch) must
// NEVER commit an open/close — mirror the never-commit-on-cancel
// contract every other gesture driver in the app upholds (curtain
// handle/body, pager, swipe-back). Drop the drag; the silhouette
// springs back to its resting state.
const applyCancel = () => {
if (!dragRef.current) return;
setDrag(null);
};
const targetIsDragOrigin = (target: EventTarget | null): boolean => {
if (!target || !(target instanceof Element)) return false;
return target.closest(DRAG_ORIGIN_SELECTOR) !== null;
@ -332,6 +342,12 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
const onTouchMove = (e: TouchEvent) => {
const d = dragRef.current;
if (!d || d.inputType !== 'touch') return;
// Second finger mid-gesture — abort without committing, mirroring
// the curtain/pager drivers.
if (e.touches.length !== 1) {
applyCancel();
return;
}
applyMove(e.touches[0].clientX, e.touches[0].clientY, e);
};
const onTouchEnd = () => {
@ -339,6 +355,11 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
if (!d || d.inputType !== 'touch') return;
applyEnd();
};
const onTouchCancel = () => {
const d = dragRef.current;
if (!d || d.inputType !== 'touch') return;
applyCancel();
};
// === Mouse / pen path ===
const onDocPointerDown = (e: PointerEvent) => {
@ -384,15 +405,21 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
if (!d || d.inputType !== 'pointer') return;
applyEnd();
};
const onDocPointerCancel = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
const d = dragRef.current;
if (!d || d.inputType !== 'pointer') return;
applyCancel();
};
document.addEventListener('touchstart', onDocTouchStart, { passive: true });
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd, { passive: true });
document.addEventListener('touchcancel', onTouchEnd, { passive: true });
document.addEventListener('touchcancel', onTouchCancel, { passive: true });
document.addEventListener('pointerdown', onDocPointerDown);
document.addEventListener('pointermove', onDocPointerMove, { passive: false });
document.addEventListener('pointerup', onDocPointerEnd, { passive: true });
document.addEventListener('pointercancel', onDocPointerEnd, { passive: true });
document.addEventListener('pointercancel', onDocPointerCancel, { passive: true });
if (handleEl) {
handleEl.addEventListener('touchstart', onHandleTouchStart, { passive: true });
handleEl.addEventListener('pointerdown', onHandlePointerDown);
@ -402,11 +429,11 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
document.removeEventListener('touchstart', onDocTouchStart);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
document.removeEventListener('touchcancel', onTouchEnd);
document.removeEventListener('touchcancel', onTouchCancel);
document.removeEventListener('pointerdown', onDocPointerDown);
document.removeEventListener('pointermove', onDocPointerMove);
document.removeEventListener('pointerup', onDocPointerEnd);
document.removeEventListener('pointercancel', onDocPointerEnd);
document.removeEventListener('pointercancel', onDocPointerCancel);
if (handleEl) {
handleEl.removeEventListener('touchstart', onHandleTouchStart);
handleEl.removeEventListener('pointerdown', onHandlePointerDown);
@ -470,6 +497,9 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
<div
className={css.appBody}
style={{
// Always emitted, even at rest where it computes to
// `inset(0 …)` — see MobileSettingsHorseshoe.tsx for the full
// always-emitted / stacking-context rationale.
clipPath: appBodyClipPath,
transition: appBodyTransition,
overscrollBehaviorY: 'contain',

View file

@ -1,5 +1,6 @@
import { atom } from 'jotai';
import { IconSrc } from 'folds';
import type { MobilePagerTab } from '../components/mobile-tabs-pager/MobilePagerPaneContext';
// Per-tab «primary action» published by a pane's StreamHeader. When
// present it replaces the default Plus / «new chat» button in the
@ -30,6 +31,16 @@ export type MobilePagerCurtainControls = {
openSearch: () => void;
closeForm: () => void;
isFormActive: boolean;
// True while the inline form is MOUNTED (`activeForm !== null`) —
// outlives the snap-based `isFormActive` through the 280480ms close
// window until `acknowledgeClosed` / the safety timer clears the form
// (useCurtainState.ts). The pager's refresh singleton gates on THIS:
// gating on `isFormActive` would pop a mascot kept alive by another
// dwelling tab in behind the still-visible closing form, whose band
// has no opaque backdrop in pager mode. `isFormActive` stays
// untouched — the static header's Search↔X swap must still flip at
// close-start.
formMounted: boolean;
// Optional tab-specific create action (the Plus button). When null
// (Direct) the static header renders NO Plus — new-chat is gone and
// people are found through search. Channels sets it to «create
@ -39,6 +50,67 @@ export type MobilePagerCurtainControls = {
export const mobilePagerCurtainAtom = atom<MobilePagerCurtainControls | null>(null);
// Per-tab pull-to-refresh reveal state, published by EVERY pane's
// StreamHeader (keyed by its `pinKey`, like `curtainPinnedByTabAtom`):
// true while that pane's curtain is at the `refresh` snap or a non-form
// drag is in flight — i.e. while the pane's refresh band is (or is
// being pulled) on screen.
//
// Consumed (ORed via `mobilePagerAnyRevealAtom`) by
// `PagerRefreshSingleton` — the strip-hosted, screen-static refresh
// chrome (one mascot card + ring field for the whole app) shows while
// ANY tab reveals. Why a per-tab map and not an active-pane boolean:
// an INACTIVE pane legitimately rests at `refresh` through its
// auto-close dwell after a swipe-away, and keeping the singleton alive
// for it makes a return swipe seamless — no dance restart.
//
// Per-key writes need no compare-and-clear choreography: each pane owns
// its key, so writers never race.
//
// Keyed by `MobilePagerTab` (not a free-form string): the pager-side
// readers index these maps by the pager's tab name, so a pane's
// `pinKey` MUST be the tab name — the type makes the compiler enforce
// what previously held only by convention.
export const curtainRefreshRevealByTabAtom = atom<Partial<Record<MobilePagerTab, boolean>>>({});
// OR over the per-tab reveal map. Derived so the singleton re-renders
// only on actual flips — jotai skips notifying when the derived value
// is Object.is-equal — not on map identity churn
// ({A:true} → {A:true,B:false}).
export const mobilePagerAnyRevealAtom = atom((get) =>
Object.values(get(curtainRefreshRevealByTabAtom)).some(Boolean)
);
// ACTIVE pane's form-MOUNTED boolean, derived from the controls atom so
// the singleton does not subscribe to the whole controls object (it is
// rewritten on activation/primaryAction changes); the boolean only
// notifies on real flips. See MobilePagerCurtainControls.formMounted
// for why MOUNTED (not the snap-based isFormActive).
export const mobilePagerFormMountedAtom = atom(
(get) => get(mobilePagerCurtainAtom)?.formMounted ?? false
);
// Imperative channel published the OTHER way — by the strip-hosted
// refresh singleton (PagerRefreshSingleton), consumed by the active
// pane's StreamHeader:
//
// * `getVideo` — the live dance <video> (null while the poster is
// shown). StreamHeader's pull-to-refresh auto-close reads the loop
// position so the curtain only retracts at a dance-loop boundary.
// * `spawnEcho` — one-shot echo pulse ring. The singleton is
// `pointer-events: none` behind the panes, so it can never receive
// the user's tap itself; the active pane's StreamHeader keeps an
// invisible tap-catcher in the mascot band and proxies pokes here.
//
// Both members are stable (the singleton publishes one object on
// mount), so consumers can safely list the api object in effect deps.
export type MobilePagerMascotApi = {
getVideo: () => HTMLVideoElement | null;
spawnEcho: () => void;
};
export const mobilePagerMascotApiAtom = atom<MobilePagerMascotApi | null>(null);
// Per-tab pinned state for the StreamHeader curtain. Each listing
// surface (Direct / Channels / Bots) reads/writes by its own stable
// `pinKey` so the pin survives the route-driven listing-pane unmount
@ -49,12 +121,13 @@ export const mobilePagerCurtainAtom = atom<MobilePagerCurtainControls | null>(nu
// 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, ... }
// Map shape (keys are the pager tab names — the type enforces the
// pinKey↔tab-name coupling that `MobileTabsPagerHeader`'s elevation
// gate relies on when it reads `pinnedByTab[activeTab]`):
// { direct: true, channels: false, bots: false }
// Missing key ⇒ false. Each tab's pin lives independently — pinning
// Direct doesn't pin Channels.
export const curtainPinnedByTabAtom = atom<Record<string, boolean>>({});
export const curtainPinnedByTabAtom = atom<Partial<Record<MobilePagerTab, boolean>>>({});
// True while a horseshoe bottom sheet (settings on Direct, workspace
// switcher on Channels) is geometrically active — i.e. `expandedPx > 0`,