From 78504262d314c46bf5983f5abe98e16ef24e5702 Mon Sep 17 00:00:00 2001 From: heaven Date: Wed, 20 May 2026 23:20:17 +0300 Subject: [PATCH] feat(stream-header): flatten web curtain to a tabsRow-border divider pixel-aligned with PageHeader at WEB_TABS_ROW_PX=54 and gate keyboard probe to native --- .../stream-header/StreamHeader.css.ts | 54 ++++++++++++++++--- .../components/stream-header/StreamHeader.tsx | 25 ++++++++- src/app/components/stream-header/geometry.ts | 20 +++++++ 3 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/app/components/stream-header/StreamHeader.css.ts b/src/app/components/stream-header/StreamHeader.css.ts index 90971dda..6a5cff1d 100644 --- a/src/app/components/stream-header/StreamHeader.css.ts +++ b/src/app/components/stream-header/StreamHeader.css.ts @@ -1,6 +1,6 @@ import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { color, toRem } from 'folds'; +import { color, config, toRem } from 'folds'; import { CHIP_GAP_PX, CURTAIN_BREATHER_PX, @@ -9,6 +9,7 @@ import { CURTAIN_SNAP_MS, HANDLE_HEIGHT_PX, TABS_ROW_PX, + WEB_TABS_ROW_PX, } from './geometry'; // Stage. Position-relative anchor. The header itself paints the @@ -63,13 +64,31 @@ export const header = style({ }); // Tabs row. Stays fully visible regardless of curtain position -// because the curtain's `top` floor equals `TABS_ROW_PX`. +// because the curtain's `top` floor equals `TABS_ROW_PX` on native +// (`WEB_TABS_ROW_PX` on web — see `geometry.ts::WEB_TABS_ROW_PX`). +// +// Web variant: shrink to `WEB_TABS_ROW_PX` (= 54 px = folds Header +// `size="600"`) so the row reads at the same height as the right-pane +// room `PageHeader`, AND own the 1 px divider rule as a +// `border-bottom`. Putting the rule on `tabsRow` (not on the curtain +// as a `border-top`) is load-bearing for pixel alignment: with the +// global `* { box-sizing: border-box }` reset (`src/index.css`), +// `tabsRow`'s 1 px bottom border lands at y=53→54 inside the 54 px +// box — exactly where PageHeader's outlined border-bottom paints. If +// the rule lived on the curtain's `border-top` at `top: 54`, it would +// paint at y=54→55, off-by-one against the right pane. export const tabsRow = style({ flexShrink: 0, height: toRem(TABS_ROW_PX), display: 'flex', alignItems: 'center', padding: `0 ${toRem(8)}`, + selectors: { + '[data-platform="web"] &': { + height: toRem(WEB_TABS_ROW_PX), + borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + }, + }, }); export const tabsCluster = style({ @@ -92,16 +111,29 @@ export const iconsCluster = style({ // child (DirectSelfRow / WorkspaceFooter) stays glued to the visible // viewport bottom regardless of where the curtain's top is. // -// Only the TOP corners are rounded: the bottom is meant to read as -// continuous with the always-visible bottomPinned row (DirectSelfRow -// is the curtain's last flex child) — adding `borderBottomRadius` -// would crop the row's corners against the curtain's -// `overflow: hidden`, which visually reads as «a light-blue strip -// cuts into the row». +// On native, only the TOP corners are rounded: the bottom is meant +// to read as continuous with the always-visible bottomPinned row +// (DirectSelfRow is the curtain's last flex child) — adding +// `borderBottomRadius` would crop the row's corners against the +// curtain's `overflow: hidden`, which visually reads as «a light- +// blue strip cuts into the row». // // Live finger tracking and snap commits both flow through React state // updates to `top` so the transition is always coordinated with the // rendered position — disabled during drag, restored on commit. +// +// Web variant (`[data-platform="web"]` on `stage`, set by +// StreamHeader.tsx when `!isNativePlatform()`): there is no pin/peek +// gesture, so the curtain is a purely static slab under the tabs row. +// Drop ONLY the «card» rounding (top corners flat). The divider rule +// at the seam is owned by `tabsRow.borderBottom` under the same +// selector — that placement keeps the rule pixel-aligned with the +// right-pane `PageHeader`'s outlined border (see `tabsRow` comment +// above). The curtain bg stays `Background.Container` so the chat- +// row rows (`NavItem variant="Background"`) keep blending into one +// continuous list surface — if we made the curtain transparent the +// rows would paint as dark cards over the lighter +// `SurfaceVariant.Container` stage. export const curtain = style({ position: 'absolute', left: 0, @@ -119,6 +151,12 @@ export const curtain = style({ // Hint the compositor while the curtain is moving. Cheap since the // curtain is the only element in this stacking context that animates. willChange: 'top', + selectors: { + '[data-platform="web"] &': { + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + }, + }, }); // Drag handle at the top of the curtain. Dedicated touch surface for diff --git a/src/app/components/stream-header/StreamHeader.tsx b/src/app/components/stream-header/StreamHeader.tsx index 7de19608..05a766c1 100644 --- a/src/app/components/stream-header/StreamHeader.tsx +++ b/src/app/components/stream-header/StreamHeader.tsx @@ -21,6 +21,7 @@ import { StreamHeaderPrimaryAction, mobilePagerCurtainAtom, } from '../../state/mobilePagerHeader'; +import { TABS_ROW_PX, WEB_TABS_ROW_PX } from './geometry'; import { settingsSheetAtom } from '../../state/settingsSheet'; import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet'; import * as css from './StreamHeader.css'; @@ -247,9 +248,20 @@ export function StreamHeader({ // stage (= y = safe-top in viewport), covering the tabs row. The // global pinned atom shares this state across every listing tab so // swiping between Direct / Channels / Bots preserves the lock. + // + // `platformOffset` is the web-only shift that lifts every non-pinned + // snap by the delta between native and web tabs-row heights. Tabs + // row on web is `WEB_TABS_ROW_PX` (= 54px, matching PageHeader on + // the right pane); `snapTopPx` is computed against `TABS_ROW_PX` + // (= 64px) which stays authoritative for native pin/peek geometry. + // Subtracting the delta on web realigns the closed/form snaps with + // the smaller tabs row without touching the snap-state machine. + // Pinned (= 0) doesn't need the offset because the safe-top + native + // contract owns that case and pinned is native-only. + const platformOffset = isNativePlatform() ? 0 : WEB_TABS_ROW_PX - TABS_ROW_PX; const curtainTop = curtain.pinned ? 0 + curtain.liveDragPx - : snapTopPx(curtain.snap, curtain.formHeightPx) + curtain.liveDragPx; + : snapTopPx(curtain.snap, curtain.formHeightPx) + platformOffset + curtain.liveDragPx; // After the curtain settles at `closed`, unmount any lingering form. // Guarded so unrelated transitionend events (e.g. children's own @@ -286,6 +298,15 @@ export function StreamHeader({ // spurious flips on small browser-chrome animations. const [keyboardOpen, setKeyboardOpen] = useState(false); useEffect(() => { + // Desktop browsers / Electron have no soft keyboard, but their + // VisualViewport DOES shrink on aggressive page zoom (Ctrl+`+`). + // That used to slip past as a hidden quirk while the curtain + // rendered as a rounded card; once the web variant flattened the + // curtain it became a visible regression — the DirectSelfRow at + // the bottom would collapse to height: 0 under zoom and read as + // broken layout. Gate the listener to native so the probe only + // arms where a real soft keyboard can actually appear. + if (!isNativePlatform()) return undefined; const vv = window.visualViewport; if (!vv) return undefined; const KEYBOARD_PROBE_PX = 100; @@ -308,7 +329,7 @@ export function StreamHeader({ }, []); return ( -
+
{/* ── Tabs row + action icons (always visible) ─────────── In pager mode the row stays mounted (curtain snap math diff --git a/src/app/components/stream-header/geometry.ts b/src/app/components/stream-header/geometry.ts index 30088219..1d3f2f74 100644 --- a/src/app/components/stream-header/geometry.ts +++ b/src/app/components/stream-header/geometry.ts @@ -34,6 +34,26 @@ // Tabs row height. Always visible above the curtain. export const TABS_ROW_PX = 64; +// Web-only tabs-row height override. Matches folds `
` +// = 3.375rem = 54 px, which is the height the right-side `PageHeader` +// in the room chat panel (see `components/page/Page.tsx::PageHeader`) +// renders at. The 1 px divider rule on web lives as `tabsRow. +// borderBottom` so — under the global `* { box-sizing: border-box }` +// reset — it lands at y=53→54 inside this 54 px box, exactly matching +// where PageHeader's `outlined: true` border-bottom paints on the +// right pane. The two panes thus share one visible header baseline. +// +// Native keeps `TABS_ROW_PX` because the pin-gesture travel +// (`PIN_TRAVEL_PX`) is anchored to it and shrinking it would change +// the curtain's snap geometry. Web has no pin gesture, so the override +// is applied through two coordinated levers: +// 1. CSS `[data-platform="web"] &` selectors on `tabsRow` (height +// → `WEB_TABS_ROW_PX`, plus the divider as `borderBottom`). +// 2. TSX `platformOffset = WEB_TABS_ROW_PX - TABS_ROW_PX` (= -10) +// added to `curtainTop` so the closed / peek / form snaps all +// ride the reduced tabs row without recomputing `snapTopPx`. +export const WEB_TABS_ROW_PX = 54; + // Each peek-chip row. Reveals one chip's pill (h=48) + 8px top breather. export const CHIP_ROW_PX = 56;