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

This commit is contained in:
v.lagerev 2026-05-20 23:20:17 +03:00
parent ab75a178e4
commit 298669a084
3 changed files with 89 additions and 10 deletions

View file

@ -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

View file

@ -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 (
<div className={css.stage}>
<div className={css.stage} data-platform={isNativePlatform() ? undefined : 'web'}>
<header className={css.header}>
{/* Tabs row + action icons (always visible)
In pager mode the row stays mounted (curtain snap math

View file

@ -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 `<Header size="600">`
// = 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;