diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index e8b212ef..746465e6 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -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 `` 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 `` 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/`. diff --git a/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx b/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx index f6e3c901..d53ae4ef 100644 --- a/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx +++ b/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx @@ -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} > -
+ {/* 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. */} +
- {isFormActive ? ( - - - - ) : ( + {isFormActive ? null : (
{/* Default «new chat» Plus is gone — people are found through search. Only the tab's `primaryAction` (Channels' diff --git a/src/app/components/stream-header/StreamHeader.css.ts b/src/app/components/stream-header/StreamHeader.css.ts index d9b1504e..9eac470c 100644 --- a/src/app/components/stream-header/StreamHeader.css.ts +++ b/src/app/components/stream-header/StreamHeader.css.ts @@ -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`, }); diff --git a/src/app/components/stream-header/StreamHeader.tsx b/src/app/components/stream-header/StreamHeader.tsx index 48eb0a0f..20d67167 100644 --- a/src/app/components/stream-header/StreamHeader.tsx +++ b/src/app/components/stream-header/StreamHeader.tsx @@ -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( () => ({ 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({
- {isActive ? ( - - - - ) : ( + {/* 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 : (
{/* 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({ )}
- {/* ── 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 ? ( -
-
- -
-
- ) : ( - 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} {/* ── Radar-pulse overlay (non-pager native surfaces only) ─ @@ -746,6 +790,44 @@ export function StreamHeader({
)}
+ + {/* ── 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 && ( +
+
+ +
+
+ )} + + {/* 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( + ); } diff --git a/src/app/components/stream-header/forms/InlineRoomSearch.tsx b/src/app/components/stream-header/forms/InlineRoomSearch.tsx index 1333fd65..825b72ce 100644 --- a/src/app/components/stream-header/forms/InlineRoomSearch.tsx +++ b/src/app/components/stream-header/forms/InlineRoomSearch.tsx @@ -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 ( - {/* ── 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". */} - - - + {/* `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". */} + - {directoryLoading ? ( - - ) : ( - query.length > 0 && ( - - - - ) - )} + > + + + {directoryLoading ? ( + + ) : ( + query.length > 0 && ( + + + + ) + )} + + + + {/* ── Result list ────────────────────────────────────── */} diff --git a/src/app/components/stream-header/geometry.ts b/src/app/components/stream-header/geometry.ts index 64c9b501..8f3b4fe1 100644 --- a/src/app/components/stream-header/geometry.ts +++ b/src/app/components/stream-header/geometry.ts @@ -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; diff --git a/src/app/components/stream-header/useCurtainBodyGesture.ts b/src/app/components/stream-header/useCurtainBodyGesture.ts index 1f19ec78..5a8912a6 100644 --- a/src/app/components/stream-header/useCurtainBodyGesture.ts +++ b/src/app/components/stream-header/useCurtainBodyGesture.ts @@ -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 diff --git a/src/app/components/stream-header/useCurtainHandleGesture.ts b/src/app/components/stream-header/useCurtainHandleGesture.ts index c29c4993..a7445859 100644 --- a/src/app/components/stream-header/useCurtainHandleGesture.ts +++ b/src/app/components/stream-header/useCurtainHandleGesture.ts @@ -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, diff --git a/src/app/components/stream-header/useCurtainState.ts b/src/app/components/stream-header/useCurtainState.ts index 7123be28..11dd33ca 100644 --- a/src/app/components/stream-header/useCurtainState.ts +++ b/src/app/components/stream-header/useCurtainState.ts @@ -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; // 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('closed'); const [activeForm, setActiveForm] = useState(null); - const [formHeightPx, setFormHeightPx] = useState(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(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(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, diff --git a/src/app/state/mobilePagerHeader.ts b/src/app/state/mobilePagerHeader.ts index c8caeee9..d13eca2c 100644 --- a/src/app/state/mobilePagerHeader.ts +++ b/src/app/state/mobilePagerHeader.ts @@ -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 280–480ms 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