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. */}
+
{open && portalTarget
? createPortal(
{children}
@@ -625,10 +582,13 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
className={css.silhouette}
style={{
height: `${expandedPx}px`,
- borderTopLeftRadius: `${silhouetteRadiusPx}px`,
- borderTopRightRadius: `${silhouetteRadiusPx}px`,
transition: silhouetteTransition,
- visibility: expandedPx > 0 ? 'visible' : 'hidden',
+ // Gate on `renderSettings` (open / dragging / exiting), NOT on
+ // `expandedPx > 0` — on close `expandedPx` snaps to 0 while the
+ // height transition animates the retract; gating on it would
+ // hide the sheet on the first frame and the slide-down would
+ // never be seen. Same rationale as the media-viewer sheet.
+ visibility: renderSettings ? 'visible' : 'hidden',
// Reset `--vojo-safe-top` for everything mounted inside the
// sheet. The status-bar inset is reserved by PageNav's inner
// column via `padding-top: var(--vojo-safe-top)` for surfaces
diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx
index 62c7328b..a371034e 100644
--- a/src/app/features/settings/Settings.tsx
+++ b/src/app/features/settings/Settings.tsx
@@ -1,33 +1,24 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import {
- Box,
- Button,
- color,
- config,
- Icon,
- IconButton,
- Icons,
- IconSrc,
- MenuItem,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Text,
-} from 'folds';
-import FocusTrap from 'focus-trap-react';
+import { Box, color, config, Icon, IconButton, Icons, IconSrc, Text } from 'folds';
import { General } from './general';
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { Account } from './account';
import { Notifications } from './notifications';
import { Devices } from './devices';
-import { EmojisStickers } from './emojis-stickers';
import { About } from './about';
import { Network } from './network';
import { UseStateProvider } from '../../components/UseStateProvider';
-import { stopPropagation } from '../../utils/keyboard';
-import { LogoutDialog } from '../../components/LogoutDialog';
+import { LogoutDialog } from '../../components/logout-dialog';
+import { useAuthedUserId } from '../../hooks/useAuthedUserId';
+import { useUserProfile } from '../../hooks/useUserProfile';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
+import { nameInitials } from '../../utils/common';
+import { UserAvatar } from '../../components/user-avatar';
+import * as css from './styles.css';
export enum SettingsPages {
GeneralPage,
@@ -35,13 +26,16 @@ export enum SettingsPages {
NotificationPage,
NetworkPage,
DevicesPage,
- EmojisStickersPage,
AboutPage,
}
// Stable string keys for URL deep-links: `/settings?page=devices`.
-// Append-only — the enum is local but the param values are part of
-// the URL contract (push notifications, bookmarks, external links).
+// Existing values never get RENAMED — the enum is local but the param
+// values are part of the URL contract (push notifications, bookmarks,
+// external links). A key may be dropped together with its page (the
+// 'emojis' key died with the emojis-stickers page); stale inbound
+// links then fall back to the settings landing, which is the correct
+// degradation for a removed surface.
// `as const satisfies` so the value type is a literal union (not
// widened to `Record`): the lookup
// `SETTINGS_PAGE_PARAM[k]` with `k: keyof typeof SETTINGS_PAGE_PARAM`
@@ -56,41 +50,88 @@ export const SETTINGS_PAGE_PARAM = {
notifications: SettingsPages.NotificationPage,
network: SettingsPages.NetworkPage,
devices: SettingsPages.DevicesPage,
- emojis: SettingsPages.EmojisStickersPage,
about: SettingsPages.AboutPage,
} as const satisfies Record;
export const SETTINGS_PARAM_DEVICES = 'devices';
-// DOM id of the visible "Settings" title in PageNavHeader — referenced
-// from `aria-labelledby` on the mobile sheet's `role="dialog"` so
-// screen readers announce the same string sighted users see (WAI-ARIA
-// APG dialog pattern prefers `aria-labelledby` over `aria-label`).
-export const SETTINGS_TITLE_ID = 'vojo-settings-title';
+type MenuRowTint = 'violet' | 'amber' | 'blue' | 'green' | 'rose' | 'neutral';
type SettingsMenuItem = {
page: SettingsPages;
nameKey: string;
icon: IconSrc;
+ tint: MenuRowTint;
};
+// The Account page is NOT in this list — the profile hero at the top of the
+// menu is its entry (avatar + name + mxid, tap to open). One muted Dawn
+// accent per row, per the design bundle's sidebar vocabulary.
const SETTINGS_MENU_ITEMS: SettingsMenuItem[] = [
- { page: SettingsPages.GeneralPage, nameKey: 'Settings.menu_general', icon: Icons.Setting },
- { page: SettingsPages.AccountPage, nameKey: 'Settings.menu_account', icon: Icons.User },
+ {
+ page: SettingsPages.GeneralPage,
+ nameKey: 'Settings.menu_general',
+ icon: Icons.Setting,
+ tint: 'violet',
+ },
{
page: SettingsPages.NotificationPage,
nameKey: 'Settings.menu_notifications',
icon: Icons.Bell,
+ tint: 'amber',
},
- { page: SettingsPages.NetworkPage, nameKey: 'Settings.network.menu', icon: Icons.Server },
- { page: SettingsPages.DevicesPage, nameKey: 'Settings.menu_devices', icon: Icons.Monitor },
{
- page: SettingsPages.EmojisStickersPage,
- nameKey: 'Settings.menu_emojis_stickers',
- icon: Icons.Smile,
+ page: SettingsPages.NetworkPage,
+ nameKey: 'Settings.network.menu',
+ icon: Icons.Server,
+ tint: 'blue',
+ },
+ {
+ page: SettingsPages.DevicesPage,
+ nameKey: 'Settings.menu_devices',
+ icon: Icons.Monitor,
+ tint: 'green',
+ },
+ {
+ page: SettingsPages.AboutPage,
+ nameKey: 'Settings.menu_about',
+ icon: Icons.Info,
+ tint: 'neutral',
},
- { page: SettingsPages.AboutPage, nameKey: 'Settings.menu_about', icon: Icons.Info },
];
+// Own-profile hero at the top of the settings menu — avatar, display name,
+// mono mxid. Tapping opens the Account page (where avatar/name editing
+// lives). Replaces the old «Account» menu row.
+function SettingsProfileHero({ active, onClick }: { active: boolean; onClick: () => void }) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const userId = useAuthedUserId();
+ const profile = useUserProfile(userId);
+
+ const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
+ const avatarUrl = profile.avatarUrl
+ ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
+ : undefined;
+
+ return (
+
+ );
+}
+
type SettingsProps = {
initialPage?: SettingsPages;
requestClose: () => void;
@@ -101,9 +142,13 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
// `initialPage !== undefined` (not truthy-check) — `GeneralPage`
// happens to be enum value `0`, which the old `if (initialPage)`
// form silently dropped, breaking `/settings?page=general`.
+ //
+ // Desktop lands on the ACCOUNT page (the user's own profile) — settings
+ // open on «me» first, mirroring the profile hero that tops the menu.
+ // Mobile keeps the bare menu (the hero IS the landing there).
const [activePage, setActivePage] = useState(() => {
if (initialPage !== undefined) return initialPage;
- return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage;
+ return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.AccountPage;
});
// Re-sync when the parent passes a new `initialPage` (e.g. the
// UnverifiedTab shield-icon shortcut navigating from `/settings` to
@@ -148,6 +193,17 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
) {
return;
}
+ // An overlay floating above the sub-page (folds dialogs/menus all
+ // portal into #portalContainer; the horseshoe sheets' display:none
+ // markers don't count) owns Esc / Android back. Window-capture runs
+ // before the overlay's document-level focus-trap listener, so
+ // without this bail we'd pop the page out from under an open
+ // dialog — e.g. mid-logout or mid-UIA on the Devices page.
+ const portal = document.getElementById('portalContainer');
+ const overlayOpen = portal
+ ? Array.from(portal.children).some((c) => (c as HTMLElement).style.display !== 'none')
+ : false;
+ if (overlayOpen) return;
setActivePage(undefined);
e.stopImmediatePropagation();
};
@@ -162,7 +218,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
-
+
{t('Settings.title')}
@@ -188,89 +244,58 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
-
- {menuItems.map((item) => {
- const isActive = activePage === item.page;
- // The whole PageNav sits on SurfaceVariant.Container
- // (the chat-pane tone). Active items step up to
- // SurfaceVariant.ContainerActive (the raised
- // hover/active tone) to read as a subtle raised
- // row, and the active icon picks up
- // `Primary.Main` (Fleet-violet) for a single
- // splash of accent — the same accent the
- // DM/Channels/Bots tab underline uses. Text stays
- // neutral OnContainer; the weight bump conveys
- // the active state to the eye.
- return (
-
- }
- onClick={() => setActivePage(item.page)}
- style={{
- backgroundColor: isActive
- ? color.SurfaceVariant.ContainerActive
- : 'transparent',
- }}
- >
-
+ setActivePage(SettingsPages.AccountPage)}
+ />
+