From 0956e58cfda16242ea7261222e08b849c22302ff Mon Sep 17 00:00:00 2001 From: heaven Date: Mon, 15 Jun 2026 02:57:32 +0300 Subject: [PATCH] refactor(settings): restructure user settings into a profile hero with tinted menu rows and a near-fullscreen mobile sheet --- .../MobileTabsPagerHeader.tsx | 49 +- .../components/mobile-tabs-pager/style.css.ts | 41 +- .../common-settings/SettingsNav.css.ts | 44 +- .../features/common-settings/SettingsNav.tsx | 15 +- .../settings/MobileSettingsHorseshoe.css.ts | 99 ++-- .../settings/MobileSettingsHorseshoe.tsx | 226 +++---- src/app/features/settings/Settings.tsx | 254 ++++---- .../features/settings/devices/DeviceTile.tsx | 115 ++-- src/app/features/settings/devices/Devices.tsx | 48 +- .../settings/devices/OtherDevices.tsx | 10 +- .../emojis-stickers/EmojisStickers.tsx | 30 - .../settings/emojis-stickers/GlobalPacks.tsx | 553 ------------------ .../settings/emojis-stickers/UserPack.tsx | 64 -- .../settings/emojis-stickers/index.ts | 1 - src/app/features/settings/styles.css.ts | 196 ++++++- .../features/space-settings/SpaceSettings.tsx | 8 + .../channels/ChannelsWorkspaceHorseshoe.tsx | 25 +- src/app/state/mobilePagerHeader.ts | 32 +- 18 files changed, 673 insertions(+), 1137 deletions(-) delete mode 100644 src/app/features/settings/emojis-stickers/EmojisStickers.tsx delete mode 100644 src/app/features/settings/emojis-stickers/GlobalPacks.tsx delete mode 100644 src/app/features/settings/emojis-stickers/UserPack.tsx delete mode 100644 src/app/features/settings/emojis-stickers/index.ts diff --git a/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx b/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx index ebe824d1..f6e3c901 100644 --- a/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx +++ b/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx @@ -4,7 +4,7 @@ import { useAtomValue } from 'jotai'; import { Box, Icon, IconButton, Icons } from 'folds'; import { curtainPinnedByTabAtom, - mobileHorseshoeActiveAtom, + mobileHorseshoeElevateHeaderAtom, mobilePagerCurtainAtom, } from '../../state/mobilePagerHeader'; import { Segment } from '../stream-header/Segment'; @@ -89,42 +89,37 @@ export function MobileTabsPagerHeader({ // the «curtain-overlay invariants» comment in style.css.ts on // pagerStaticHeader for the bg / z-order contract. // - // Z-elevation while a horseshoe sheet is GEOMETRICALLY active: the - // MobileSettings / ChannelsWorkspace container paints - // `VOJO_HORSESHOE_VOID_COLOR` (= #000 in dark theme) across the - // entire pane to drive the carve cut-out the moment `expandedPx > 0`. - // Without elevation that void bleeds up through the transparent - // strip-stack into the safe-top + tabsRow zone, turning the system- - // tray strip + tabs black. Bumping the static header into a positive - // z-index puts it ABOVE the strip's stacking context (positive z - // beats z:auto stacking contexts per CSS painting order), covering - // the void in its own y-band with SurfaceVariant bg + visible tabs. - // - // The atom tracks the GEOMETRIC signal (`expandedPx > 0`), not the - // sheet-open atoms, so elevation lands on the FIRST frame of drag — - // not 80 px later when the user crosses the commit threshold. The - // horseshoes' appBody flips to opaque in lockstep (same signal), - // containing the void to the bottom carve everywhere below the - // static header. + // Z-elevation is requested ONLY by the void-carve sheet design (the + // Channels workspace switcher): while geometrically active + // (`expandedPx > 0`, first frame of drag) its container paints + // `VOJO_HORSESHOE_VOID_COLOR` across the pane to drive the carve, + // and without elevation that void bleeds up through the transparent + // strip-stack into the safe-top + tabsRow zone. A positive z-index + // beats the strip's z:auto stacking context (CSS painting order), + // covering the void in this band with SurfaceVariant bg + visible + // tabs. The near-fullscreen Settings sheet keeps its appBody + // transparent and simply slides OVER this header like a real + // curtain, so it never publishes the elevate atom — see + // mobileHorseshoeElevateHeaderAtom's docs. // // Pinned-overrides-elevation: when the active pane's curtain is // pinned the curtain itself contains the void — it covers everything // from `y = safe-top` downward inside the strip's stacking context, - // and the opaque appBody (also flipped on `horseshoeActive`) covers - // the safe-top band above the curtain. Re-elevating the static - // header in that state would visibly «slice» the pinned curtain in - // the safe-top + tabsRow band, popping tabs back over what the user - // explicitly pulled up to cover. So we suppress elevation whenever - // the active tab's pin is set — preserves the «pinned hides tabs» - // invariant across sheet open/drag. + // and the opaque appBody (flipped on the same geometric signal) + // covers the safe-top band above the curtain. Re-elevating the + // static header in that state would visibly «slice» the pinned + // curtain in the safe-top + tabsRow band, popping tabs back over + // what the user explicitly pulled up to cover. So we suppress + // elevation whenever the active tab's pin is set — preserves the + // «pinned hides tabs» invariant across sheet open/drag. // // The curtain pin gesture is suppressed while either sheet is open // (see `StreamHeader.gestureDisabled`), so this elevation never // races with a pin-in-progress drag. - const horseshoeActive = useAtomValue(mobileHorseshoeActiveAtom); + const elevateHeader = useAtomValue(mobileHorseshoeElevateHeaderAtom); const pinnedByTab = useAtomValue(curtainPinnedByTabAtom); const activePinned = !!pinnedByTab[activeTab]; - const elevated = horseshoeActive && !activePinned; + const elevated = elevateHeader && !activePinned; return (
0`, which covers both -// the in-flight drag and the committed-open state — the wrapping +// The two horseshoe sheet designs interact differently with this +// header. The near-fullscreen Settings sheet keeps its appBody +// transparent and simply slides its opaque silhouette OVER the tabs +// like a real curtain — no elevation, the tabs stay put underneath. +// The Channels workspace switcher is the void-carve design: while +// geometrically active (`expandedPx > 0`, first frame of drag) its // container paints `VOJO_HORSESHOE_VOID_COLOR` (= #000 in dark -// theme) across the entire pane so the carve at the sheet's top -// reads as a dark seam. With the transparent strip stack from (b), -// that void would bleed up through the safe-top + tabsRow zone, -// turning the system-tray strip + tabs solid black. +// theme) across the pane to drive the carve. With the transparent +// strip stack from (b), that void would bleed up through the +// safe-top + tabsRow zone, turning the system-tray strip + tabs +// solid black. // -// `MobileTabsPagerHeader.tsx` bumps this element to a positive -// `zIndex` (inline style, driven by `mobileHorseshoeActiveAtom`) -// from the first frame of drag. Positive z beats the strip's -// `z: auto` stacking context, putting the static header back on -// top in the safe-top + tabsRow band — the void is contained to -// the carve area, tabs stay visible. The horseshoe's `appBody` -// flips back to opaque on the same signal so the void doesn't -// bleed into the mascot/form band between the static header and -// the curtain top either. The curtain pin gesture is gated off -// in the same state (see `StreamHeader.gestureDisabled`) so no -// pin can race the elevation flip. +// So only the void-carve sheet publishes +// `mobileHorseshoeElevateHeaderAtom`; `MobileTabsPagerHeader.tsx` +// bumps this element to a positive `zIndex` (inline style) on that +// signal. Positive z beats the strip's `z: auto` stacking context, +// putting the static header back on top in the safe-top + tabsRow +// band — the void is contained to the carve area, tabs stay +// visible. That horseshoe's `appBody` flips back to opaque on the +// same signal so the void doesn't bleed into the band between the +// static header and the curtain top either. The curtain pin gesture +// is gated off in the same state (see `StreamHeader.gestureDisabled`) +// so no pin can race the elevation flip. // // Pinned-override: when the active pane's curtain is pinned, the // curtain itself sits at the top of the stage (z:2 inside the diff --git a/src/app/features/common-settings/SettingsNav.css.ts b/src/app/features/common-settings/SettingsNav.css.ts index 0bf939ce..7e0a0707 100644 --- a/src/app/features/common-settings/SettingsNav.css.ts +++ b/src/app/features/common-settings/SettingsNav.css.ts @@ -1,4 +1,4 @@ -import { style } from '@vanilla-extract/css'; +import { globalStyle, style } from '@vanilla-extract/css'; import { color, config, DefaultReset, FocusOutline, toRem } from 'folds'; // Dawn settings rail — uppercase tracked muted labels, raised active row with a @@ -34,6 +34,11 @@ export const NavSection = style([ }, ]); +// Menu row — same Fleet vocabulary as the user-settings menu: tinted +// rounded-square glyph chip (rendered by SettingsNav.tsx via the shared +// MenuRowIcon recipe), label, trailing chevron. Active row raises on the +// panel tone with a weight bump; mouse-only hover (Android WebView's +// synthesised sticky `:hover` — see src/index.tsx). export const NavItem = style([ DefaultReset, FocusOutline, @@ -43,40 +48,27 @@ export const NavItem = style([ alignItems: 'center', gap: config.space.S300, width: '100%', - padding: `${config.space.S300} ${config.space.S300}`, + minHeight: toRem(46), + padding: `${config.space.S200} ${config.space.S300}`, marginBottom: toRem(2), - borderRadius: config.radii.R400, + borderRadius: toRem(12), color: color.Surface.OnContainer, cursor: 'pointer', + transition: 'background-color 120ms ease-out', selectors: { - '&:hover': { backgroundColor: color.Background.ContainerHover }, + '&:active': { backgroundColor: color.Background.ContainerActive }, '&[aria-pressed=true]': { backgroundColor: color.Background.ContainerActive }, - '&[aria-pressed=true]::before': { - content: '""', - position: 'absolute', - left: toRem(5), - top: '50%', - transform: 'translateY(-50%)', - width: toRem(3), - height: '52%', - borderRadius: toRem(3), - backgroundColor: color.Primary.Main, - }, }, }, ]); -export const NavItemIcon = style({ - opacity: 0.6, - selectors: { - [`${NavItem}[aria-pressed=true] &`]: { - color: color.Primary.Main, - opacity: 1, - }, - }, +globalStyle(`:root[data-input="mouse"] ${NavItem}:hover`, { + backgroundColor: color.Background.ContainerHover, }); export const NavItemLabel = style({ + flexGrow: 1, + minWidth: 0, fontWeight: config.fontWeight.W500, selectors: { [`${NavItem}[aria-pressed=true] &`]: { @@ -84,3 +76,9 @@ export const NavItemLabel = style({ }, }, }); + +export const NavItemChevron = style({ + flexShrink: 0, + color: color.Surface.OnContainer, + opacity: 0.4, +}); diff --git a/src/app/features/common-settings/SettingsNav.tsx b/src/app/features/common-settings/SettingsNav.tsx index 998ad95f..c543e199 100644 --- a/src/app/features/common-settings/SettingsNav.tsx +++ b/src/app/features/common-settings/SettingsNav.tsx @@ -1,20 +1,29 @@ import React, { ReactNode } from 'react'; -import { Icon, IconSrc, Text } from 'folds'; +import { Icon, Icons, IconSrc, Text } from 'folds'; +import { MenuRowIcon } from '../settings/styles.css'; import * as css from './SettingsNav.css'; +// One muted Dawn accent per row — the same tinted rounded-square glyph +// vocabulary the user-settings menu uses (features/settings/styles.css). +export type SettingsNavTint = 'violet' | 'amber' | 'blue' | 'green' | 'rose' | 'neutral'; + type SettingsNavItemProps = { icon: IconSrc; label: string; active: boolean; + tint?: SettingsNavTint; onClick: () => void; }; -export function SettingsNavItem({ icon, label, active, onClick }: SettingsNavItemProps) { +export function SettingsNavItem({ icon, label, active, tint, onClick }: SettingsNavItemProps) { return ( ); } diff --git a/src/app/features/settings/MobileSettingsHorseshoe.css.ts b/src/app/features/settings/MobileSettingsHorseshoe.css.ts index 0580fe70..38345c48 100644 --- a/src/app/features/settings/MobileSettingsHorseshoe.css.ts +++ b/src/app/features/settings/MobileSettingsHorseshoe.css.ts @@ -1,19 +1,13 @@ import { style } from '@vanilla-extract/css'; import { color, toRem } from 'folds'; -import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe'; - -// Re-exported so the TSX can pick up the constants without crossing -// the vanilla-extract / runtime boundary twice. -export const HORSESHOE_RADIUS_PX = VOJO_HORSESHOE_RADIUS_PX; -export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX; +import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe'; // Outer container — `position: relative` anchor for the two absolutely- // positioned panes (`appBody` and `silhouette`). `overflow: hidden` -// clips anything that overflows the wrapper's bounds and crops the -// rounded carves on both panes against the container's bg (which is -// painted with `VOJO_HORSESHOE_VOID_COLOR` inline when the sheet is -// active, so the carved-out areas read as the same near-black seam -// used everywhere else in the app). +// clips the full-bleed children at the screen edges; the rounded top +// corners come from the silhouette's own static border-radius, whose +// corner triangles stay TRANSPARENT and reveal the live DM list behind +// (the media-viewer composition — no void colour, no carve). // // `flex: 1` so the container fills whatever flex slot it's mounted in // (PageNav's inner column for the Direct route). @@ -22,16 +16,10 @@ export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX; // status-bar safe-top zone reserved by `PageNav` via `padding-top`, // and the compensating `paddingTop: var(--vojo-safe-top)` on `appBody` // keeps the wrapped DM list anchored at the same visual Y as before -// the shift. The combination has two load-bearing effects: -// -// (1) The settings-sheet clip-path mask on `appBody` carves rounded -// BL/BR into an opaque surface that already paints THROUGH the -// status-bar strip — without the upward extension the carve -// would visibly stop at the bottom of the system-tray strip. -// (2) `appBody`'s bg paints the safe-top strip itself in the same -// `SurfaceVariant.Container` tone as `PageNav-inner` / the -// pager's static header, giving the system-tray text a -// consistent backdrop across surfaces. +// the shift. The extension is what lets the near-fullscreen sheet's +// top edge (railHeight = containerHeight − safeTop) land exactly at +// the bottom of the status bar, while `appBody`'s bg paints the strip +// in the same `SurfaceVariant.Container` tone as the pager header. // // Note: the curtain never RESTS inside the safe-top zone — every snap // destination floors at `top: 0` of the stage (= `y = safe-top` in @@ -52,38 +40,25 @@ export const container = style({ }); // === App body === Holds the wrapped children (the DM list — header, -// scroll content, DirectSelfRow). Fills the container via `inset: 0`. -// Does NOT translate or shrink — the DM list stays exactly where it -// was in the closed state. Instead, the bottom of the pane is masked -// away by an animated `clip-path: -// inset(...)` with rounded BL/BR corners — the user sees the visible -// top portion of the DM list with a rounded carve at the new bottom -// edge, exactly like the profile horseshoe shows the chat with a -// rounded TOP carve as the panel masks it from above. +// scroll content, DirectSelfRow), full-bleed behind the silhouette via +// `inset: 0`. NOT clipped or translated: the DM list keeps its layout, +// scroll position and measured row heights; the opaque sheet simply +// grows over it, and the sheet's transparent rounded corners reveal +// this live surface — exactly the media-viewer composition. // -// Why clip-path rather than flex-shrink + margin-bottom: a flex- -// shrink approach changes the scroll-container's height every render, -// and the DM list's `@tanstack/react-virtual` re-measures items mid- -// gesture. Why not `transform: translateY` either: translating moves -// the whole pane up off the top of the viewport, including the -// StreamHeader the user wants to keep visible. Clip-path leaves -// layout unchanged — the DM list keeps its scroll position, its -// measured heights, and its top items in place; only the bottom edge -// of what's visible gets carved into the void below. +// `isolation: isolate` is load-bearing: it makes appBody a PERMANENT +// stacking context, which the pager refresh singleton's paint-order +// contract counts on (see mobile-tabs-pager/style.css.ts:: +// pagerRefreshSingleton). The always-on clip-path used to provide this +// as a side effect; with the carve gone, isolate keeps the guarantee. // -// `backgroundColor: SurfaceVariant.Container` is load-bearing on two -// counts: (1) it must be OPAQUE so the container's void colour -// (painted inline when the sheet is active) doesn't bleed through gaps -// between DM list rows; (2) the safe-top padding region of THIS -// element is what paints the system-tray strip when the wrapper -// container is extended up over it (see `container.marginTop` above). -// Picking `SurfaceVariant.Container` (not `Background.Container`) -// matches the Bots / ChannelsRoot status-bar tone exactly — Bots -// renders `PageNav-inner.bg = SurfaceVariant.Container` in the safe- -// top zone, and the StreamHeader curtain (`Background.Container`) -// overpaints that lighter strip with the darker tone as it's dragged -// up. Mirroring the same two tones here gives Direct the same visible -// «curtain darkens the strip» transition the user expects. +// `backgroundColor: SurfaceVariant.Container` is load-bearing: it must +// be OPAQUE so nothing behind the wrapper bleeds through gaps between +// DM-list rows, and the safe-top padding region of THIS element is +// what paints the system-tray strip when the wrapper container is +// extended up over it (see `container.marginTop` above). Picking +// `SurfaceVariant.Container` (not `Background.Container`) matches the +// Bots / ChannelsRoot status-bar tone exactly. // // `flex: column` so the children (which expect a flex column parent // — PageNav uses it) still stack naturally. `paddingTop: @@ -102,18 +77,17 @@ export const appBody = style({ minHeight: 0, backgroundColor: color.SurfaceVariant.Container, paddingTop: 'var(--vojo-safe-top, 0px)', - willChange: 'clip-path', + isolation: 'isolate', }); // === Silhouette === The Settings sheet's surface. Anchored at the // bottom of the container; its height animates 0 → railHeight as the -// user drags up. Rounded TL/TR carve the top edge against the void -// gap between it and the translated-up appBody. -// -// `overflow: hidden` clips `panelContent` (which is railHeight tall, -// top-anchored) so the visible portion of the panel is just the -// silhouette's current height — the user sees more of the panel -// content reveal from the top as silhouette grows. +// user drags up. STATIC rounded top corners (same radius as the tab +// curtains / media sheet); `overflow: hidden` clips the panel content +// to the curve, and the two corner triangles paint NOTHING — they stay +// transparent and reveal the live DM list behind (`appBody` is +// full-bleed, not clipped), so the rounding reads against real content +// with no backing square. Only the height animates (`willChange`). // // Background: `SurfaceVariant.Container` (Dawn bg = #181a20) — the // chat-pane tone, same as the Settings PageNav inside (set via @@ -122,9 +96,6 @@ export const appBody = style({ // matches the PageNav tone. With `Background.Container` (#0d0e11) // here, the user saw a dark stripe at that seam on Samsung S24 // edge-to-edge; matching silhouette to the PageNav tone closes it. -// Same idea as commit 77bb72d which dynamically retunes -// `--vojo-safe-area-bg` while a Room is mounted to keep the -// system-bar strips and the chat surface in lockstep. export const silhouette = style({ position: 'absolute', bottom: 0, @@ -134,7 +105,9 @@ export const silhouette = style({ flexDirection: 'column', overflow: 'hidden', backgroundColor: color.SurfaceVariant.Container, - willChange: 'height, border-top-left-radius, border-top-right-radius', + borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX), + borderTopRightRadius: toRem(VOJO_HORSESHOE_RADIUS_PX), + willChange: 'height', }); // Anchored at the TOP of `silhouette` so as silhouette grows from 0 diff --git a/src/app/features/settings/MobileSettingsHorseshoe.tsx b/src/app/features/settings/MobileSettingsHorseshoe.tsx index 93dec21a..f252308e 100644 --- a/src/app/features/settings/MobileSettingsHorseshoe.tsx +++ b/src/app/features/settings/MobileSettingsHorseshoe.tsx @@ -1,28 +1,18 @@ -// Bottom-up «horseshoe» sheet that wraps the mobile Direct DM list. -// Mirror of `MobileProfileHorseshoe` in features/room — the chat -// there is wrapped by a top-down horseshoe (panel above, chat below -// with a 12px void). Here we invert: the wrapped app body is above, -// the Settings sheet emerges from below, and a 12px void separates -// them in a )|( silhouette. +// Bottom-up near-fullscreen Settings sheet that wraps the mobile +// Direct DM list. Geometry mirrors the MEDIA-VIEWER sheet +// (`MobileMediaViewerHorseshoe`): the opaque sheet grows from the +// bottom over the full-bleed app body and stops just under the system +// status bar; its STATIC rounded top corners stay transparent and +// reveal the live DM list behind. No void gap, no clip-path carve, no +// emerge ramp — only the sheet's height animates. // // User-visible behaviour: // // • The wrapped app body (StreamHeader → DM list → DirectSelfRow) -// stays exactly where it was — no translate, -// no shrink. The bottom of the visible portion is "masked away" -// by an animated `clip-path: inset(0 0 BOTTOMpx 0 round 0 0 Rpx -// Rpx)` with rounded BL/BR carves at the new visible edge. The -// carved area exposes the container's void colour underneath, and -// the silhouette below covers the rest of the masked zone. Why -// not `transform: translateY`: translating moves the TOP of the -// pane out of the viewport (StreamHeader scrolls off- -// screen). Why not `flex-shrink + margin-bottom`: the virtualized -// DM list (`@tanstack/react-virtual`) re-measures items every -// time the scroll container resizes — items above the shrinking -// edge visibly smear. Clip-path leaves layout unchanged: the DM -// list keeps its scroll position, its measured heights, and its -// top items in place; only the bottom edge of what's visible is -// carved into the void. +// stays exactly where it was — no translate, no shrink, no clip. +// The virtualized DM list (`@tanstack/react-virtual`) keeps its +// scroll position and measured row heights; the sheet simply +// paints over it. // • Drag-up origin is `DirectSelfRow` itself, marked with the // `data-settings-drag-origin` attribute. A document-level // touchstart / pointerdown listener uses `target.closest()` to @@ -56,7 +46,6 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { HorseshoeEnabledContext } from '../../components/page'; import { useMobilePagerPane } from '../../components/mobile-tabs-pager/MobilePagerPaneContext'; import { mobileHorseshoeActiveAtom } from '../../state/mobilePagerHeader'; -import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe'; import { Settings } from './Settings'; import * as css from './MobileSettingsHorseshoe.css'; @@ -72,26 +61,6 @@ const ANIMATION_MS = 250; // or close from the panel handle). Mirrors the profile horseshoe's // 80px so the two gestures feel identical. const COMMIT_THRESHOLD_PX = 80; -// Fixed sheet height — 2/3 of viewport, what the user signed off on. -// Internal scrolling inside Settings sub-pages handles content -// overflow; no rail re-sizing on menu↔sub-page navigation. -const RAIL_FRACTION = 2 / 3; -// Drag distance over which the radii + void-gap ramp from 0 to their -// full value during finger-drag — same as the profile horseshoe's -// `HORSESHOE_EMERGE_PX`. Matched to `COMMIT_THRESHOLD_PX` so the -// silhouette is fully formed exactly when the gesture qualifies to -// commit. -const HORSESHOE_EMERGE_PX = 80; - -// Symmetric cubic in-out — slow start, fast middle, slow finish. Mirror -// of the profile horseshoe's emerge curve (file rationale there). The -// linear ramp from round 1 came out too "snappy" because the rounding -// jumped in within the first ~10px of drag; the cubic keeps the corners -// barely visible until ~40% of the way through the gesture, then -// blossoms around the midpoint. Used only during finger-drag; release -// transitions use the asymmetric VAUL_EASING curve in CSS. -const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2); - type DragSource = 'directSelfRow' | 'handle'; // Axis dead-zone for horizontal-bail. The finger must travel this far @@ -129,17 +98,50 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) const inPagerMode = useMobilePagerPane() !== null; const [drag, setDrag] = useState(null); - const [viewportHeight, setViewportHeight] = useState(() => + + // Measure the wrapper container directly via `ResizeObserver` — the + // container extends up over the status bar (css `marginTop: + // -var(--vojo-safe-top)`), so its measured height already includes the + // strip; subtracting the live safe-top inset below puts the sheet's top + // edge exactly at the bottom of the status bar. Mirrors the media-viewer + // sheet's calc (MobileMediaViewerHorseshoe). + const containerRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(() => typeof window === 'undefined' ? 800 : window.innerHeight ); - - useEffect(() => { - const onResize = () => setViewportHeight(window.innerHeight); - window.addEventListener('resize', onResize); - return () => window.removeEventListener('resize', onResize); + useLayoutEffect(() => { + const el = containerRef.current; + if (!el) return undefined; + setContainerHeight(el.clientHeight); + const ro = new ResizeObserver((entries) => { + const cr = entries[0]?.contentRect; + if (cr) setContainerHeight(cr.height); + }); + ro.observe(el); + return () => ro.disconnect(); }, []); - const railHeightPx = Math.round(viewportHeight * RAIL_FRACTION); + // Live status-bar inset. `env(safe-area-inset-top)` can't be read + // reliably off a custom property in JS, so we measure a zero-width + // probe whose height is `var(--vojo-safe-top)` (= the env value). + const safeTopRef = useRef(null); + const [safeTopPx, setSafeTopPx] = useState(0); + useLayoutEffect(() => { + const el = safeTopRef.current; + if (!el) return undefined; + setSafeTopPx(el.offsetHeight); + const ro = new ResizeObserver(() => setSafeTopPx(el.offsetHeight)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Near-fullscreen by design (user request: Settings covers the app body + // and stops just under the system status bar / tray): the rail is the + // measured container height minus the live status-bar inset, so the + // sheet's rounded top edge lands at the bottom of the status bar, never + // over the tray icons. Shrunken wrappers (call rail / split-screen) + // shrink the sheet with them via the ResizeObserver above. + const railHeightPx = Math.max(0, containerHeight - safeTopPx); const open = !!sheet; // Entry-animation gate. On cold-start deep-links (push notification @@ -195,15 +197,16 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) const expandedPx = drag ? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY)) : baseExpanded; - const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0; const isDragging = drag !== null; const horseshoeActive = expandedPx > 0; // Bridge our local `horseshoeActive` (geometric) signal up to the - // pager-shared atom so `MobileTabsPagerHeader` can z-elevate the - // static header from the first frame of drag — see the atom's docs - // for the «no black flash» rationale. The cleanup writing `false` - // covers route unmount mid-drag. + // pager-shared atom — it only feeds the refresh singleton's hide. The + // static pager header is deliberately NOT z-elevated for this sheet + // (no `mobileHorseshoeElevateHeaderAtom` publication): the appBody + // stays transparent in pager mode, so the tabs remain visible exactly + // as at rest and the opaque sheet simply slides OVER them like a real + // curtain. The cleanup writing `false` covers route unmount mid-drag. const setMobileHorseshoeActive = useSetAtom(mobileHorseshoeActiveAtom); useEffect(() => { setMobileHorseshoeActive(horseshoeActive); @@ -506,48 +509,12 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) }; }, []); - // Geometry — radii and void gap ramp through `easeInOutCubic` (slow - // start → fast middle → slow finish) during finger-drag, matching - // the profile horseshoe's emerge curve. Same `HORSESHOE_EMERGE_PX` - // window as the profile horseshoe so the rounding finishes exactly - // when the gesture qualifies to commit. During release (not - // dragging), the values jump to their full target and the CSS - // transition (VAUL_EASING) carries them visually. - // - // The mask: appBody stays in place, its bottom edge is "carved - // away" by `clip-path: inset(0 0 BOTTOMpx 0 round 0 0 Rpx Rpx)` - // where BOTTOM = expandedPx + voidGap. The carved zone exposes the - // container's bg (the void colour) above the silhouette; the void - // gap is just `voidGap` pixels of that exposed bg between the - // silhouette's top edge and the clip-path's lower edge. - let horseshoeRamp: number; - if (isDragging) { - horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX)); - } else { - horseshoeRamp = expandedFraction > 0 ? 1 : 0; - } - const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX; - const appBodyRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX; - const appBodyGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX; - const appBodyMaskBottomPx = expandedPx + appBodyGapPx; - - // `inset()` shorthand: top right bottom left, then `round` followed - // by 4 corner radii in TL TR BR BL order. Only the bottom two carry - // the radius so the visible top portion of appBody has rounded BL/BR - // at the clip boundary. Always emitted (even when all values are 0 - // for the closed state) so CSS can transition smoothly between - // closed and open — interpolating between `inset(...)` and an - // `undefined` clip-path would snap rather than animate. - const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px round 0px 0px ${appBodyRadiusPx}px ${appBodyRadiusPx}px)`; - - const silhouetteTransition = isDragging - ? 'none' - : `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`; - const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`; - - const containerStyle: React.CSSProperties = { - backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined, - }; + // Geometry — media-viewer style (MobileMediaViewerHorseshoe): the opaque + // silhouette simply grows from the bottom over the full-bleed appBody. + // Its rounded TL/TR corners are STATIC (32px, in the css) and stay + // transparent, revealing the live DM list behind — no clip-path carve, + // no void gap, no emerge ramp. Only the silhouette's height animates. + const silhouetteTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`; const settingsState = sheet ?? lastSheetRef.current; const renderSettings = keepMounted || isDragging; @@ -568,7 +535,22 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) : null; return ( -
+
+ {/* Zero-width probe — its height resolves `env(safe-area-inset-top)` + so the JS rail-height calc can keep the sheet below the tray. */} +