feat(search): open listing search as a full-stage overlay over the tabs — right-side close ✕, hardware-back/Escape dismiss, flush result clip

This commit is contained in:
heaven 2026-06-29 21:48:43 +03:00
parent 80c64e0760
commit d599c98a62
10 changed files with 331 additions and 213 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` + `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`.
- `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 `form-search` snap no longer drops a panel *below* the tabs row — tapping Search now opens `InlineRoomSearch` as a **full-stage overlay** (`StreamHeader.css.ts::formOverlay`, z above the curtain) that covers the tabs row AND the chat list and fills the stage height; its opaque `SurfaceVariant` backdrop is what hides the tabs (load-bearing in pager mode where the static header would show through), the overlay never covers the safe-top status-bar strip, and it carries its own back/✕ + fade (`StreamHeader.formVisible`). The curtain itself stays at `closed` behind it. 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

@ -75,7 +75,6 @@ export function MobileTabsPagerHeader({
const curtainControls = useAtomValue(mobilePagerCurtainAtom);
const isFormActive = curtainControls?.isFormActive ?? false;
const openSearch = useCallback(() => curtainControls?.openSearch(), [curtainControls]);
const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]);
const iconsDisabled = curtainControls === null;
// Tab-specific create action (Channels publishes «create channel» /
// «create community»). When null (Direct) NO Plus renders — new-chat
@ -126,7 +125,12 @@ export function MobileTabsPagerHeader({
className={css.pagerStaticHeader}
style={elevated ? { zIndex: PAGER_HEADER_ELEVATED_Z } : undefined}
>
<div className={streamHeaderCss.tabsRow}>
{/* While search is active the full-stage overlay (in the active
pane's stage, painted by the strip above this header) covers
this row AND carries its own close so the row is aria-hidden
and renders no close here, avoiding a duplicate occluded-but-
focusable «Close» in the a11y tree. */}
<div className={streamHeaderCss.tabsRow} aria-hidden={isFormActive || undefined}>
<div className={streamHeaderCss.tabsCluster}>
<Segment
active={activeTab === 'direct'}
@ -147,21 +151,7 @@ export function MobileTabsPagerHeader({
)}
</div>
<Box grow="Yes" />
{isFormActive ? (
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={closeForm}
aria-label={t('Direct.close')}
aria-controls={INLINE_FORM_ID}
aria-expanded
disabled={iconsDisabled}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
) : (
{isFormActive ? null : (
<div className={streamHeaderCss.iconsCluster}>
{/* Default «new chat» Plus is gone people are found through
search. Only the tab's `primaryAction` (Channels'

View file

@ -433,26 +433,66 @@ export const mascotCaption = style({
whiteSpace: 'nowrap',
});
// Active form area in the header. Outer is `position: relative`; the
// inner mounted form fills it with `top: 0` so the form's input bar
// sits flush just below the tabs row.
export const formArea = style({
position: 'relative',
flexShrink: 0,
overflow: 'hidden',
// Full-stage search overlay. Replaces the old «form drops below the
// tabs row» reveal: tapping the Search icon now opens the search form
// AS a layer (zIndex 3, above the curtain's z:2) that covers the tabs
// row AND the chat list and fills the whole stage height — the user's
// «move the search up, hide the tabs behind the search bar, stretch it
// full height» request.
//
// The opaque `SurfaceVariant.Container` backdrop is what hides the tabs
// — load-bearing in PAGER mode, where the per-pane stage/header are
// transparent and the static pager header (segments) sits behind the
// strip; without an opaque fill the tabs would show through. It matches
// the static header tone so the safe-top strip ABOVE the overlay (the
// stage starts at y = safe-top, the overlay never covers the status-bar
// inset) blends seamlessly into the search surface below it.
//
// `data-open` (driven by StreamHeader's `formVisible`, flipped one
// frame after mount / cleared on close) fades + slides the overlay in
// on open and back out on close, before `activeForm` is unmounted by
// the curtain-state safety timer. Reduced-motion drops the slide.
export const formOverlay = style({
position: 'absolute',
inset: 0,
zIndex: 3,
display: 'flex',
flexDirection: 'column',
backgroundColor: color.SurfaceVariant.Container,
opacity: 0,
transform: 'translateY(-8px)',
transition: `opacity ${CURTAIN_SNAP_MS}ms ${CURTAIN_SNAP_EASING}, transform ${CURTAIN_SNAP_MS}ms ${CURTAIN_SNAP_EASING}`,
// `pointer-events: none` at rest so the closing/invisible overlay
// doesn't swallow taps during its fade-out: on close the curtain's
// `top` doesn't change (form-search snap == closed), so nothing
// unmounts the overlay until the safety timer ~480ms later — and the
// opaque z:3 layer would otherwise eat every tap meant for the chat
// list / tabs underneath for ~200ms after it has faded to opacity 0.
// Restored to `auto` only while open (below) so the form stays
// interactive.
pointerEvents: 'none',
selectors: {
'&[data-open="true"]': {
opacity: 1,
transform: 'none',
pointerEvents: 'auto',
},
},
'@media': {
'(prefers-reduced-motion: reduce)': {
transform: 'none',
transition: 'opacity 120ms linear',
},
},
});
export const formInner = style({
position: 'absolute',
top: 0,
left: 0,
right: 0,
// 12px side padding insets the search bar a little from the column edge.
//
// Bottom padding is 0 (not 8 like the top): the form's last child is
// the scrollable result list, and it must run flush into the curtain's
// top edge. Any bottom padding here would reintroduce a light-blue strip
// that clips the search results before they reach the curtain. The 8px
// top padding stays: it's the gap below the tabs row.
// Padded inner column the search form fills. Side inset matches the old
// form; the small top inset sits the search bar a touch below the
// status-bar safe area; the result list grows to the bottom edge.
export const formOverlayInner = style({
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
padding: `${toRem(8)} ${toRem(12)} 0`,
});

View file

@ -9,6 +9,7 @@ import React, {
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useMatch, useNavigate } from 'react-router-dom';
import { useAtomValue, useSetAtom } from 'jotai';
@ -219,6 +220,72 @@ export function StreamHeader({
});
const isActive = isFormSnap(curtain.snap);
// Entrance / exit fade for the full-stage search overlay. `open()`
// sets `activeForm` + the form snap synchronously, so the overlay
// mounts already-active; flipping `formVisible` true on the NEXT frame
// lets the CSS opacity/translate transition play from the hidden
// initial state instead of snapping straight to opaque. On close
// (snap leaves `form-search`) it flips back to false so the overlay
// fades out before the curtain-state safety timer unmounts `activeForm`.
const [formVisible, setFormVisible] = useState(false);
useEffect(() => {
if (!isActive) {
setFormVisible(false);
return undefined;
}
const id = requestAnimationFrame(() => setFormVisible(true));
return () => cancelAnimationFrame(id);
}, [isActive]);
// Hardware-back / Escape closes the search overlay. Mirrors the mobile
// sheets (MobileSettingsHorseshoe / ChannelsWorkspaceHorseshoe):
//
// 1. While the form is the visible-active one, render an invisible
// marker into `#portalContainer`. The global `useAndroidBackButton`
// handler dispatches a synthetic `Escape` keydown whenever that
// container has a child (instead of popping navigation), so
// Android's hardware back reaches us as an Escape.
// 2. A window `keydown` listener closes the form on Escape — covers
// both the synthetic native Escape and a real desktop Escape.
//
// Gated to the ACTIVE pager pane so a search left open on an off-screen
// pane (you can swipe away mid-search) doesn't swallow the back press
// for the tab you're actually looking at.
const backIntercepts = isActive && (!inPagerMode || isActivePagerPane);
const { close: closeForm } = curtain;
useEffect(() => {
if (!backIntercepts) return undefined;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
// NB: unlike the settings/workspace sheets this otherwise mirrors,
// there is deliberately NO INPUT/TEXTAREA target bail here — for a
// search surface Escape should close even while the (autofocused)
// query field has focus, matching the desktop Search-modal
// convention. Don't add the sheet-style input guard back.
//
// If a real overlay (the start-DM confirm dialog) is open above the
// search, let it own Escape — don't tear down the whole search.
const portal = document.getElementById('portalContainer');
if (
portal &&
Array.from(portal.children).some(
(c) => !(c instanceof HTMLElement && c.dataset.vojoSearchBackMarker)
)
) {
return;
}
closeForm();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [backIntercepts, closeForm]);
const backMarkerTarget =
typeof document !== 'undefined'
? document.getElementById('portalContainer') ?? document.body
: null;
// 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
@ -407,12 +474,11 @@ export function StreamHeader({
const pagerControls = useMemo<MobilePagerCurtainControls>(
() => ({
openSearch,
closeForm: close,
isFormActive: isActive,
formMounted,
primaryAction: primaryAction ?? null,
}),
[openSearch, close, isActive, formMounted, primaryAction]
[openSearch, isActive, formMounted, primaryAction]
);
const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom);
@ -451,7 +517,7 @@ export function StreamHeader({
const platformOffset = isNativePlatform() ? 0 : WEB_TABS_ROW_PX - TABS_ROW_PX;
const curtainTop = curtain.pinned
? 0 + curtain.liveDragPx
: snapTopPx(curtain.snap, curtain.formHeightPx) + platformOffset + curtain.liveDragPx;
: snapTopPx(curtain.snap) + platformOffset + curtain.liveDragPx;
// After the curtain settles at `closed`, unmount any lingering form.
// Guarded so unrelated transitionend events (e.g. children's own
@ -568,7 +634,7 @@ export function StreamHeader({
<div
className={css.tabsRow}
style={inPagerMode ? { opacity: 0 } : undefined}
aria-hidden={inPagerMode || undefined}
aria-hidden={inPagerMode || isActive || undefined}
>
<div className={css.tabsCluster}>
<Segment
@ -590,20 +656,13 @@ export function StreamHeader({
)}
</div>
<Box grow="Yes" />
{isActive ? (
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={close}
aria-label={t('Direct.close')}
aria-controls={INLINE_FORM_ID}
aria-expanded
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
) : (
{/* While search is active the full-stage overlay covers this row
AND carries its own close , so we render NO close here a
second occluded-but-focusable «Close» would duplicate the
control in the a11y tree and let Shift+Tab land on an
invisible button. The whole row is also aria-hidden while
active (it's covered). */}
{isActive ? null : (
<div className={css.iconsCluster}>
{/* The default «new chat» Plus is gone people are found
through search now. Only the tab-specific `primaryAction`
@ -642,31 +701,16 @@ export function StreamHeader({
)}
</div>
{/* Mascot-refresh card vs search form
Mutually exclusive. While the search form is mounted
(including the curtain's close-snap window before
`acknowledgeClosed`), the mascot card stays unrendered so the
form doesn't visually jump mid-animation. Otherwise the mascot
card sits in its fixed header position below the tabs row and
the curtain occludes it by z-stacking; dragging the curtain
down to `refresh` reveals it from underneath. */}
{curtain.activeForm ? (
<div
id={INLINE_FORM_ID}
role="region"
aria-label={t('Search.search')}
className={css.formArea}
style={{
height: toRem(curtain.formHeightPx ?? 0),
}}
>
<div ref={curtain.formMeasureRef} className={css.formInner}>
<InlineRoomSearch onClose={close} />
</div>
</div>
) : (
refreshSlot
)}
{/* Mascot-refresh card
The search form is no longer hosted here it's a full-stage
overlay below (see `formOverlay`). While a form is mounted
(including the close-snap window before the safety timer
unmounts it) the mascot card stays unrendered so it can't
flash behind the fading overlay. Otherwise the mascot card
sits in its fixed header position below the tabs row and the
curtain occludes it by z-stacking; dragging the curtain down
to `refresh` reveals it from underneath. */}
{curtain.activeForm ? null : refreshSlot}
</header>
{/* Radar-pulse overlay (non-pager native surfaces only)
@ -746,6 +790,44 @@ export function StreamHeader({
</div>
)}
</div>
{/* Full-stage search overlay
Mounted while a form is active. Sits ABOVE the curtain (z:3 vs
z:2) and fills the whole stage (`inset: 0`), so the search bar
covers the tabs row and the result list takes over the chat
column «search up, tabs hidden behind the bar, full height».
The stage starts at y = safe-top, so the overlay never covers
the status-bar inset. Its opaque SurfaceVariant backdrop is
what hides the tabs (load-bearing in pager mode where the
static header would otherwise show through). `formVisible`
drives the fade/slide in and out. */}
{curtain.activeForm && (
<div
id={INLINE_FORM_ID}
role="region"
aria-label={t('Search.search')}
className={css.formOverlay}
data-open={formVisible || undefined}
>
<div className={css.formOverlayInner}>
<InlineRoomSearch onClose={close} />
</div>
</div>
)}
{/* Invisible portal marker so the global `useAndroidBackButton`
handler treats hardware-back as an Escape (which the effect
above turns into a form close) instead of popping navigation. */}
{backIntercepts &&
backMarkerTarget &&
createPortal(
<div
data-vojo-search-back-marker="true"
aria-hidden="true"
style={{ display: 'none' }}
/>,
backMarkerTarget
)}
</div>
);
}

View file

@ -17,7 +17,6 @@ import { nameInitials } from '../../../utils/common';
import { getMxIdLocalPart, getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix';
import { getDmUserId, useRoomSearch } from '../../../features/search/useRoomSearch';
import { StartDirectDialog } from '../../../features/search/StartDirectDialog';
import { SEARCH_FORM_BASE_PX } from '../geometry';
// Shared inline result-row chrome (rooms / people / address card).
const ROW_BASE_STYLE: React.CSSProperties = {
@ -210,70 +209,91 @@ export function InlineRoomSearch({ onClose }: Props) {
return (
<Box
direction="Column"
gap="200"
// Fill the form's full outer height so the result list runs flush
// into the curtain's top edge (the form snap drops the breather —
// see `snapTopPx`). `- 8` accounts for `formInner`'s 8px top
// padding (StreamHeader.css.ts), so measured `formInner.offsetHeight`
// lands at exactly `SEARCH_FORM_BASE_PX`, keeping the curtain at its
// resting top and the pre-measure fallback in lockstep.
style={{ width: '100%', height: toRem(SEARCH_FORM_BASE_PX - 8) }}
grow="Yes"
// No column gap between the search bar and the result list: the
// scroll viewport's top edge (where rows clip as you scroll) sits
// flush under the input's bottom border, so there's no light-blue
// strip where text disappears early. Per-row padding still gives
// the first result a little air at rest, and it scrolls away under
// the input cleanly.
//
// Fill the full-stage overlay (`StreamHeader.css.ts::formOverlay`)
// so the result list runs all the way to the bottom edge. The
// overlay box shrinks with the layout viewport when the soft
// keyboard rises (`interactive-widget=resizes-content`), so the
// form stays fully above the keyboard with no manual offset.
style={{ width: '100%', minHeight: 0 }}
>
{/* ── Input bar (h=48 / r=20 / pad 8/14 pill).
`width: 100%` + `minWidth: 0` keep the bar a constant full
width whether empty or typed without it the flex row
collapses to its content (the search icon) until text widens
it, which read as the bar "growing as you type". */}
<Box
shrink="No"
alignItems="Center"
style={{
width: '100%',
minWidth: 0,
backgroundColor: color.Background.Container,
borderRadius: toRem(20),
padding: `${toRem(8)} ${toRem(14)}`,
height: toRem(48),
gap: toRem(10),
}}
>
<Icon size="50" src={Icons.Search} />
<input
ref={inputRef}
type="text"
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
placeholder={t('Search.search')}
autoComplete="off"
{/* Search-bar row: the input pill with a close on the RIGHT
restores the original tabs-row position the overlay now
covers. Tapping it (or hardware / Escape back) closes the
search and brings the tabs back. */}
<Box shrink="No" alignItems="Center" gap="100">
{/* `grow` + `minWidth: 0` keep the pill a constant full width
whether empty or typed without it the flex row collapses to
its content (the search icon) until text widens it, which read
as the bar "growing as you type". */}
<Box
grow="Yes"
alignItems="Center"
style={{
flex: 1,
appearance: 'none',
border: 'none',
outline: 'none',
background: 'transparent',
font: 'inherit',
fontSize: toRem(14),
color: color.Background.OnContainer,
minWidth: 0,
backgroundColor: color.Background.Container,
borderRadius: toRem(20),
padding: `${toRem(8)} ${toRem(14)}`,
height: toRem(48),
gap: toRem(10),
}}
/>
{directoryLoading ? (
<Spinner size="200" variant="Secondary" />
) : (
query.length > 0 && (
<IconButton
type="button"
size="300"
radii="Pill"
variant="SurfaceVariant"
fill="None"
onClick={clearSearch}
aria-label={t('Search.clear')}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
)
)}
>
<Icon size="50" src={Icons.Search} />
<input
ref={inputRef}
type="text"
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
placeholder={t('Search.search')}
autoComplete="off"
style={{
flex: 1,
appearance: 'none',
border: 'none',
outline: 'none',
background: 'transparent',
font: 'inherit',
fontSize: toRem(14),
color: color.Background.OnContainer,
minWidth: 0,
}}
/>
{directoryLoading ? (
<Spinner size="200" variant="Secondary" />
) : (
query.length > 0 && (
<IconButton
type="button"
size="300"
radii="Pill"
variant="SurfaceVariant"
fill="None"
onClick={clearSearch}
aria-label={t('Search.clear')}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
)
)}
</Box>
<IconButton
type="button"
size="400"
radii="Pill"
variant="SurfaceVariant"
fill="None"
onClick={onClose}
aria-label={t('Direct.close')}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
{/* ── Result list ────────────────────────────────────── */}

View file

@ -4,9 +4,12 @@
// Mental model: the chats card is a curtain layered ABOVE the header
// (z-index higher). The curtain's `top` is the visible part of the
// header below the always-pinned tabs row. When the curtain is fully
// closed it sits flush under the tabs row (covering the mascot card +
// form area beneath). Dragging it DOWN reveals more of the header from
// closed it sits flush under the tabs row (covering the mascot card
// beneath). Dragging it DOWN reveals more of the header from
// underneath. Dragging UP raises the curtain back over the header.
// (The search form is NOT a header child the curtain reveals — it's a
// separate full-stage overlay layered ABOVE the curtain; see the
// `form:*` snap below and StreamHeader.css.ts::formOverlay.)
//
// Snap stops (curtain.top, px):
// pinned = 0 (curtain sits flush at top of the stage, tabs row
@ -17,9 +20,11 @@
// refresh = TABS_ROW_PX + MASCOT_CARD_PX (dancing-mascot pull-to-
// refresh card fully revealed; a MOMENTARY snap — entering
// it pokes /sync and auto-closes once sync returns)
// form:* = TABS_ROW_PX + formHeight (NO breather — the search
// results clip flush at the curtain's top edge; the
// form's own height already reaches the curtain)
// form:* = TABS_ROW_PX (== closed — the curtain does NOT move
// when search opens; the search form is a separate
// full-stage overlay layered ABOVE the curtain, z:3,
// covering the tabs row + chat list. See
// StreamHeader.css.ts::formOverlay)
//
// Pinned visual contract: at `pinned` the curtain's top edge RESTS at
// y = safe-top in viewport coords (because the stage starts after the
@ -121,11 +126,6 @@ export const PULSE_CENTER_Y_PX = TABS_ROW_PX + 53;
// 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.
export const SEARCH_FORM_BASE_PX = 360;
// Curtain snap transition. Tuned tight for an in-app reveal —
// emphasized-decelerate territory.
export const CURTAIN_SNAP_MS = 280;

View file

@ -122,6 +122,11 @@ type Args = {
// Skip the scrollable-bail in that case — the body's visible area is
// the strip BELOW the form, and a drag there is unambiguously a
// form-close intent (the only valid transition from form-* snap).
// NB: this is now SHADOWED — the search form is a full-stage opaque
// overlay (StreamHeader.css.ts::formOverlay, z:3) covering the whole
// curtain body while mounted, so a form-snap drag never reaches here in
// practice (the form closes via its own ✕ / hardware-back / Escape).
// Retained for completeness / in case the overlay stops fully occluding.
//
// Pinned override: the body gesture is INERT while the curtain is
// pinned. Unpin is exclusively the handle's contract — the user has

View file

@ -104,6 +104,14 @@ export const assertNeverCurtainTransition = (_value: never): void => {};
// * refresh + DOWN → no-op (nothing lower to reveal).
// * form-* + UP → form-close.
// * form-* + DOWN → no-op (form is already the lowest snap).
//
// NB: the form-* arm is now SHADOWED in practice — the search form is a
// full-stage opaque overlay (StreamHeader.css.ts::formOverlay, z:3) that
// covers this handle while it's mounted, so touches never reach the
// curtain during a form snap and `form-close` can't actually fire (the
// form is dismissed via its own ✕ / hardware-back / Escape). The arm is
// kept for CurtainSnap exhaustiveness and in case the overlay ever stops
// occluding the handle.
export function resolveCurtainTransition(
snap: CurtainSnap,
pinned: boolean,

View file

@ -1,8 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, 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';
import { CURTAIN_SNAP_MS, MASCOT_CARD_PX, TABS_ROW_PX } from './geometry';
// Discrete snap stops for the curtain. The curtain's resting `top`
// is derived from this value plus the live finger drag delta. There
@ -16,10 +16,11 @@ export const isFormSnap = (snap: CurtainSnap): snap is 'form-search' => snap ===
export const isRefreshSnap = (snap: CurtainSnap): snap is 'refresh' => snap === 'refresh';
// Whether a form is currently rendered in the header. Stays set during
// the curtain's close transition so the form has content to slide
// behind; cleared by `acknowledgeClosed` after the snap settles at
// `closed`. There is only one form (search) — new-chat was folded
// Whether the search overlay is currently mounted. Stays set during
// the overlay's fade-out (after `snap` leaves `form-search`) so it has
// time to animate away; cleared by the safety timer once the snap has
// settled at `closed` (or `acknowledgeClosed` on the rare curtain
// transition). There is only one form (search) — new-chat was folded
// into search's People section — so this is a simple on/off flag.
export type ActiveForm = 'search' | null;
@ -45,19 +46,12 @@ export type CurtainState = {
// `top` transition: disabled while dragging (curtain tracks finger
// 1:1), restored on release so the snap commit animates smoothly.
isDragging: boolean;
// Live measured height of the active form's outer; used to compute
// the curtain's resting `top` when `snap === 'form-search'`. `null`
// while no form is mounted.
formHeightPx: number | null;
// Ref pointing at the rendered form's outer — a ResizeObserver
// watches this to feed `formHeightPx`. Consumer attaches it to the
// form's wrapping element.
formMeasureRef: React.RefObject<HTMLDivElement>;
// Open the search form. Sets `snap` and `activeForm` synchronously.
open: () => void;
// Close the curtain (raise it back to `closed`). Keeps `activeForm`
// set until the snap transition lands so the form stays mounted
// during the slide-up.
// Close: set `snap` back to `closed`. For the search form this does
// NOT move the curtain (form-search snap == closed), so there's no
// slide-up; `activeForm` is kept set so the full-stage overlay can
// fade out before the safety timer unmounts it.
close: () => void;
// Commit a snap stop directly. Used by the touch gesture on release.
// Also resets `liveDragPx` and `isDragging` in one batched update.
@ -74,28 +68,22 @@ export type CurtainState = {
acknowledgeClosed: () => void;
};
// Resting `top` (px) of the curtain for a given snap stop and the
// currently measured form height (null falls back to the base).
// Resting `top` (px) of the curtain for a given snap stop.
//
// The refresh snap rests exactly `MASCOT_CARD_PX` below the tabs row so
// the dancing-mascot card is fully revealed below the boundary.
//
// The form rests at exactly `TABS_ROW_PX + formHeight` — the search
// results are a scroll list, and any strip between the last visible
// result and the curtain's top reads as «content cut off early» rather
// than as breathing room. So the form's measured height already reaches
// the curtain top: the list clips flush at the curtain's edge.
// `SEARCH_FORM_BASE_PX` is the pre-measure fallback and is sized (= the
// form's full outer height) so it equals the measured `formH`, avoiding
// any open-time top jump.
export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
// `form-search` does NOT move the curtain: the search form is a
// full-stage overlay (`StreamHeader.css.ts::formOverlay`, z above the
// curtain) that covers the tabs row AND the chat list, so the curtain
// just rests at its `closed` position behind the opaque overlay. The
// overlay owns its own fade-in/out — see StreamHeader's `formVisible`.
export function snapTopPx(snap: CurtainSnap): number {
switch (snap) {
case 'closed':
return TABS_ROW_PX;
case 'refresh':
return TABS_ROW_PX + MASCOT_CARD_PX;
case 'closed':
case 'form-search':
return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX);
default:
return TABS_ROW_PX;
}
@ -108,7 +96,6 @@ export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
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);
const [liveDragPx, setLiveDragPx] = useState(0);
const [isDragging, setIsDragging] = useState(false);
// Per-tab pin lives in `curtainPinnedByTabAtom` so the lock survives
@ -118,8 +105,6 @@ export function useCurtainState(pinKey: MobilePagerTab): CurtainState {
const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom);
const pinned = !!pinnedMap[pinKey];
const formMeasureRef = useRef<HTMLDivElement>(null);
const setPinned = useCallback(
(next: boolean) => {
setPinnedMap((prev) => {
@ -170,8 +155,8 @@ export function useCurtainState(pinKey: MobilePagerTab): CurtainState {
// search form in place of the mascot during the dance.
//
// The 'closed' destination intentionally KEEPS `activeForm` set so the
// closing transition has form content beneath the curtain as it slides
// up — `acknowledgeClosed` clears it once the snap settles at `closed`.
// search overlay can play its fade-out before unmount — the safety
// timer below clears it once the snap has settled at `closed`.
if (next === 'refresh') setActiveForm(null);
}, []);
@ -184,24 +169,13 @@ export function useCurtainState(pinKey: MobilePagerTab): CurtainState {
if (snap === 'closed') setActiveForm(null);
}, [snap]);
// Measure the form's outer height while it's mounted. We do NOT
// overwrite the measured height to null on unmount — keeping the
// last-known height stable lets the close transition target the
// same `top` value as the open transition (no jump at SNAP_MS-end).
useLayoutEffect(() => {
if (!activeForm) return undefined;
const el = formMeasureRef.current;
if (!el) return undefined;
const measure = () => setFormHeightPx(el.offsetHeight);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [activeForm]);
// Safety-net for missed `transitionend` (route unmount mid-anim,
// browser quirks). Once snap settles at `closed`, force-drop the
// form after a generous window past the snap duration.
// Unmount the search overlay shortly after the curtain settles at
// `closed`. The overlay no longer rides the curtain's `top`
// transition (it's a separate full-stage layer with its own fade),
// so this timer — not `acknowledgeClosed`/`transitionend` — is the
// primary path that drops `activeForm` once the fade-out has played.
// `acknowledgeClosed` stays wired as a fast-path for the rare case
// the curtain DID transition (e.g. a refresh→closed settle).
const timerRef = useRef<number | null>(null);
useEffect(() => {
if (snap !== 'closed') {
@ -230,8 +204,6 @@ export function useCurtainState(pinKey: MobilePagerTab): CurtainState {
activeForm,
liveDragPx,
isDragging,
formHeightPx,
formMeasureRef,
open,
close,
commit,
@ -245,7 +217,6 @@ export function useCurtainState(pinKey: MobilePagerTab): CurtainState {
activeForm,
liveDragPx,
isDragging,
formHeightPx,
open,
close,
commit,

View file

@ -19,8 +19,10 @@ export type StreamHeaderPrimaryAction = {
// tabs row at the top of MobileTabsPager. The pager hoists the tabs +
// action icons OUT of each pane's StreamHeader so the header stays
// visually static while the listing content slides horizontally — but
// the action icons (Plus / Search / X) still need to drive the active
// pane's local curtain, so we proxy them through this atom.
// the action icons (Plus / Search) still need to drive the active
// pane's local curtain, so we proxy them through this atom. (Close is
// NOT proxied: while search is open the full-stage overlay covers this
// header and carries its own ✕, so the static header renders no close.)
//
// Lifecycle: each pane's StreamHeader subscribes itself to a
// MobilePagerPaneContext that flags whether it's the active pane. When
@ -29,7 +31,6 @@ export type StreamHeaderPrimaryAction = {
// is active at any moment, so writes don't race.
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
@ -38,8 +39,9 @@ export type MobilePagerCurtainControls = {
// 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.
// untouched — the static header still reads it to hide its action
// icons (and aria-hide the covered tabs) the moment search opens, and
// it must flip back 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