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