refactor(settings): restructure user settings into a profile hero with tinted menu rows and a near-fullscreen mobile sheet

This commit is contained in:
heaven 2026-06-15 02:57:32 +03:00
parent df9cac8f5c
commit 0956e58cfd
18 changed files with 673 additions and 1137 deletions

View file

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

View file

@ -86,28 +86,31 @@ export const pagerRoot = style({
// the tabs visually slide with the curtain. See git history for the
// user-feedback trail.
//
// Conditional z-elevation (horseshoe-active override, suppressed by pin):
// Conditional z-elevation (void-carve sheets only, suppressed by pin):
//
// When a horseshoe sheet (Settings or workspace switcher) is
// geometrically active — i.e. `expandedPx > 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

View file

@ -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,
});

View file

@ -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 (
<button type="button" className={css.NavItem} aria-pressed={active} onClick={onClick}>
<Icon className={css.NavItemIcon} src={icon} size="100" filled={active} />
<span className={MenuRowIcon({ tint: tint ?? 'neutral' })}>
<Icon src={icon} size="100" filled={active} />
</span>
<Text className={css.NavItemLabel} as="span" size="T300" truncate>
{label}
</Text>
<Icon className={css.NavItemChevron} src={Icons.ChevronRight} size="100" />
</button>
);
}

View file

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

View file

@ -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<DragState | null>(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<HTMLDivElement>(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<HTMLDivElement>(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 (
<div className={css.container} style={containerStyle}>
<div ref={containerRef} className={css.container}>
{/* Zero-width probe its height resolves `env(safe-area-inset-top)`
so the JS rail-height calc can keep the sheet below the tray. */}
<div
ref={safeTopRef}
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
width: 0,
height: 'var(--vojo-safe-top, 0px)',
pointerEvents: 'none',
visibility: 'hidden',
}}
/>
{open && portalTarget
? createPortal(
<div
@ -582,40 +564,15 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
<div
className={css.appBody}
style={{
// Always emitted, even at rest where it computes to
// `inset(0 …)` — see the `appBodyClipPath` comment above for
// why (interpolating from `undefined` would snap). Safe to
// keep always-on: the pull-to-refresh chrome is hosted in the
// strip-level singleton OUTSIDE this subtree, so the resting
// self-bounds clip slices nothing. (Bonus: a non-none
// clip-path makes appBody a permanent stacking context, which
// the singleton's paint-order contract counts on — see
// mobile-tabs-pager/style.css.ts::pagerRefreshSingleton.)
clipPath: appBodyClipPath,
transition: appBodyTransition,
overscrollBehaviorY: 'contain',
// In pager mode the appBody is transparent AT REST so the
// static pager header (sitting behind the swipe strip in DOM
// order) shows through the tabsRow zone — that's how the
// chats curtain visually rises ABOVE the header on pin.
//
// When the horseshoe is active (drag in flight OR sheet
// open) we flip the appBody back to opaque
// `SurfaceVariant.Container` (via the CSS class — unsetting
// the inline override). This contains the container's
// `VOJO_HORSESHOE_VOID_COLOR` paint to the carve cut-out at
// the bottom of the appBody. Otherwise the void would bleed
// up through every transparent pixel of the mascot/form band
// between the static header and the curtain top (refresh/form
// snaps), turning the strip black.
//
// The static-header z-elevation in `MobileTabsPagerHeader`
// tracks the same `mobileHorseshoeActiveAtom` so the static
// header z-pops above the now-opaque appBody in the safe-
// top + tabsRow band — tabs stay visible. Both gestures
// (pin + pager swipe) are gated off while a sheet is open,
// so the opaque flip can't race with curtain-show-through.
backgroundColor: inPagerMode && !horseshoeActive ? 'transparent' : undefined,
// In pager mode the appBody stays transparent THROUGHOUT —
// at rest so the static pager header (behind the strip in DOM
// order) shows through the tabsRow zone, and during drag/open
// so the tabs keep showing exactly as at rest while the opaque
// sheet slides OVER them like a real curtain. Nothing dark can
// bleed through: the pager root paints the same SurfaceVariant
// tone behind the strip, and this sheet has no void paint.
backgroundColor: inPagerMode ? 'transparent' : undefined,
}}
>
{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

View file

@ -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<string, SettingsPages>`): 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<string, SettingsPages>;
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 (
<button type="button" className={css.ProfileHero} aria-pressed={active} onClick={onClick}>
<span className={css.ProfileHeroAvatar}>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={displayName}
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
/>
</span>
<span className={css.ProfileHeroText}>
<span className={css.ProfileHeroName}>{displayName}</span>
<span className={css.ProfileHeroHandle}>{userId}</span>
</span>
<Icon className={css.ProfileHeroChevron} size="100" src={Icons.ChevronRight} />
</button>
);
}
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<SettingsPages | undefined>(() => {
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) {
<PageNav size="350" surface="surfaceVariant">
<PageNavHeader outlined={false}>
<Box grow="Yes" gap="200" alignItems="Center">
<Text id={SETTINGS_TITLE_ID} size="H4" truncate>
<Text size="H4" truncate>
{t('Settings.title')}
</Text>
</Box>
@ -188,89 +244,58 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
</PageNavHeader>
<Box grow="Yes" direction="Column">
<PageNavContent>
<div style={{ flexGrow: 1 }}>
{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 (
<MenuItem
key={item.nameKey}
variant="SurfaceVariant"
radii="400"
aria-pressed={isActive}
before={
<Icon
src={item.icon}
size="100"
filled={isActive}
style={{
color: isActive
? color.Primary.Main
: color.SurfaceVariant.OnContainer,
opacity: isActive ? 1 : 0.7,
}}
/>
}
onClick={() => setActivePage(item.page)}
style={{
backgroundColor: isActive
? color.SurfaceVariant.ContainerActive
: 'transparent',
}}
>
<Text
style={{
fontWeight: isActive ? config.fontWeight.W600 : undefined,
opacity: isActive ? 1 : 0.82,
}}
size="T300"
truncate
<Box direction="Column" gap="300" style={{ flexGrow: 1 }}>
<SettingsProfileHero
active={activePage === SettingsPages.AccountPage}
onClick={() => setActivePage(SettingsPages.AccountPage)}
/>
<div>
{menuItems.map((item) => {
const isActive = activePage === item.page;
return (
<button
key={item.nameKey}
type="button"
className={css.MenuRow}
aria-pressed={isActive}
onClick={() => setActivePage(item.page)}
>
{t(item.nameKey)}
</Text>
</MenuItem>
);
})}
</div>
<span className={css.MenuRowIcon({ tint: item.tint })}>
<Icon src={item.icon} size="100" filled={isActive} />
</span>
<span
className={css.MenuRowLabel}
style={isActive ? { fontWeight: 600 } : undefined}
>
{t(item.nameKey)}
</span>
<Icon
className={css.MenuRowChevron}
size="100"
src={Icons.ChevronRight}
/>
</button>
);
})}
</div>
</Box>
</PageNavContent>
<Box style={{ padding: config.space.S200 }} shrink="No" direction="Column">
<UseStateProvider initial={false}>
{(logout, setLogout) => (
<>
<Button
size="300"
variant="Critical"
fill="None"
radii="Pill"
before={<Icon src={Icons.Power} size="100" />}
<button
type="button"
className={`${css.MenuRow} ${css.MenuRowCritical}`}
onClick={() => setLogout(true)}
>
<Text size="B400">{t('Settings.logout')}</Text>
</Button>
{logout && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
onDeactivate: () => setLogout(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<LogoutDialog handleClose={() => setLogout(false)} />
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
<span className={css.MenuRowIcon({ tint: 'critical' })}>
<Icon src={Icons.Power} size="100" />
</span>
<span className={css.MenuRowLabel}>{t('Settings.logout')}</span>
</button>
{/* LogoutDialog owns its Overlay + FocusTrap. */}
{logout && <LogoutDialog handleClose={() => setLogout(false)} />}
</>
)}
</UseStateProvider>
@ -295,9 +320,6 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
{activePage === SettingsPages.DevicesPage && (
<Devices requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
</PageRoot>
);

View file

@ -11,23 +11,17 @@ import {
color,
Spinner,
toRem,
Overlay,
OverlayBackdrop,
OverlayCenter,
} from 'folds';
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
import FocusTrap from 'focus-trap-react';
import { IMyDevice, MatrixError } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../../utils/time';
import { BreakWord } from '../../../styles/Text.css';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SequenceCard } from '../../../components/sequence-card';
import { Mono, SettingRow } from '../styles.css';
import { LogoutDialog } from '../../../components/LogoutDialog';
import { stopPropagation } from '../../../utils/keyboard';
import { DeviceSummary, MenuRowIcon, Mono, SettingRow } from '../styles.css';
import { LogoutDialog } from '../../../components/logout-dialog';
export function DeviceTilePlaceholder() {
return (
@ -211,21 +205,8 @@ export function DeviceLogoutBtn() {
<Chip variant="Secondary" fill="Soft" radii="Pill" onClick={() => setPrompt(true)}>
<Text size="B300">{t('Settings.logout')}</Text>
</Chip>
{prompt && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
onDeactivate: handleClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<LogoutDialog handleClose={handleClose} />
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
{/* LogoutDialog owns its Overlay + FocusTrap (self-contained Vojo card). */}
{prompt && <LogoutDialog handleClose={handleClose} />}
</>
);
}
@ -269,14 +250,22 @@ export function DeviceDeleteBtn({
type DeviceTileProps = {
device: IMyDevice;
deleted?: boolean;
// Tints the leading device chip green — the «this device» accent.
current?: boolean;
refreshDeviceList: () => Promise<void>;
disabled?: boolean;
options?: ReactNode;
children?: ReactNode;
};
// Vojo device row: tinted rounded-square device chip (green for the current
// session, critical when marked for sign-out), name + last-activity column
// that toggles the technical details (id / IP / key) on tap, and a trailing
// pencil for rename + the host-provided actions (logout / delete).
export function DeviceTile({
device,
deleted,
current,
refreshDeviceList,
disabled,
options,
@ -291,48 +280,52 @@ export function DeviceTile({
setEdit(false);
}, []);
let chipTint: 'green' | 'critical' | 'neutral' = 'neutral';
if (deleted) chipTint = 'critical';
else if (current) chipTint = 'green';
return (
<>
<SettingTile
before={
<IconButton
variant={deleted ? 'Critical' : 'Secondary'}
outlined={deleted}
radii="300"
onClick={() => setDetails(!details)}
>
<Icon size="50" src={details ? Icons.ChevronBottom : Icons.ChevronRight} />
</IconButton>
}
after={
!edit && (
<Box shrink="No" alignItems="Center" gap="200">
{options}
{!deleted && (
<Chip
variant="Secondary"
radii="Pill"
onClick={() => setEdit(true)}
disabled={disabled}
>
<Text size="B300">{t('Settings.edit')}</Text>
</Chip>
)}
</Box>
)
}
>
<Text size="T300">{device.display_name ?? device.device_id}</Text>
<Box direction="Column">
<Box alignItems="Center" gap="300">
<span className={MenuRowIcon({ tint: chipTint })}>
<Icon size="100" src={Icons.Monitor} filled={!!current} />
</span>
<button
type="button"
className={DeviceSummary}
onClick={() => setDetails(!details)}
aria-expanded={details}
>
<Text size="T300" truncate style={{ fontWeight: 600 }}>
{device.display_name ?? device.device_id}
</Text>
{typeof activeTs === 'number' && <DeviceActiveTime ts={activeTs} />}
{details && (
<>
<DeviceDetails device={device} />
{children}
</>
)}
</button>
{!edit && (
<Box shrink="No" alignItems="Center" gap="200">
{!deleted && (
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
fill="None"
onClick={() => setEdit(true)}
disabled={disabled}
aria-label={t('Settings.edit')}
>
<Icon size="100" src={Icons.Pencil} />
</IconButton>
)}
{options}
</Box>
)}
</Box>
{details && (
<Box direction="Column" gap="100">
<DeviceDetails device={device} />
{children}
</Box>
</SettingTile>
)}
{edit && (
<DeviceRename
device={device}

View file

@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from 'folds';
import { SectionLabel, SettingFlatRow } from '../styles.css';
import { SectionLabel, SettingFlatGroup, SettingFlatRow } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { SettingsPage } from '../SettingsPage';
import { SettingsSection } from '../SettingsSection';
@ -93,27 +93,33 @@ export function Devices({ requestClose }: DevicesProps) {
{t('Settings.current')}
</Text>
{currentDevice ? (
<Box className={SettingFlatRow} direction="Column" gap="400">
<DeviceTile
device={currentDevice}
refreshDeviceList={refreshDeviceList}
options={<DeviceLogoutBtn />}
>
{crypto && <DeviceKeyDetails crypto={crypto} />}
</DeviceTile>
{crossSigningActive &&
verificationStatus === VerificationStatus.Unverified &&
defaultSecretStorageKeyId &&
defaultSecretStorageKeyContent && (
<VerifyCurrentDeviceTile
secretStorageKeyId={defaultSecretStorageKeyId}
secretStorageKeyContent={defaultSecretStorageKeyContent}
/>
// Inset grouped panel — same flat-group chrome the rest of the
// settings pages use; the row itself is the Vojo device row
// (green «this device» chip).
<div className={SettingFlatGroup}>
<Box className={SettingFlatRow} direction="Column" gap="400">
<DeviceTile
device={currentDevice}
current
refreshDeviceList={refreshDeviceList}
options={<DeviceLogoutBtn />}
>
{crypto && <DeviceKeyDetails crypto={crypto} />}
</DeviceTile>
{crossSigningActive &&
verificationStatus === VerificationStatus.Unverified &&
defaultSecretStorageKeyId &&
defaultSecretStorageKeyContent && (
<VerifyCurrentDeviceTile
secretStorageKeyId={defaultSecretStorageKeyId}
secretStorageKeyContent={defaultSecretStorageKeyContent}
/>
)}
{crypto && verificationStatus === VerificationStatus.Verified && (
<BackupRestoreTile crypto={crypto} />
)}
{crypto && verificationStatus === VerificationStatus.Verified && (
<BackupRestoreTile crypto={crypto} />
)}
</Box>
</Box>
</div>
) : (
<DeviceTilePlaceholder />
)}

View file

@ -3,7 +3,12 @@ import { useTranslation } from 'react-i18next';
import { Box, Button, config, Menu, Spinner, Text } from 'folds';
import { AuthDict, IMyDevice, MatrixError } from 'matrix-js-sdk';
import classNames from 'classnames';
import { SectionLabel, SettingFlatRow, SettingFlatRowCritical } from '../styles.css';
import {
SectionLabel,
SettingFlatGroup,
SettingFlatRow,
SettingFlatRowCritical,
} from '../styles.css';
import { ActionUIA, ActionUIAFlowsLoader } from '../../../components/ActionUIA';
import { DeviceDeleteBtn, DeviceTile } from './DeviceTile';
import { AsyncState, AsyncStatus, useAsync } from '../../../hooks/useAsyncCallback';
@ -108,7 +113,8 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
<Text as="span" className={SectionLabel}>
{t('Settings.others')}
</Text>
<Box direction="Column">
{/* Inset grouped panel — hairline-parted Vojo device rows. */}
<Box className={SettingFlatGroup} direction="Column">
{authMetadata && (
<Box className={SettingFlatRow} direction="Column" gap="400">
<SettingTile

View file

@ -1,30 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { GlobalPacks } from './GlobalPacks';
import { UserPack } from './UserPack';
import { ImagePack } from '../../../plugins/custom-emoji';
import { ImagePackView } from '../../../components/image-pack-view';
import { SettingsPage } from '../SettingsPage';
type EmojisStickersProps = {
requestClose: () => void;
};
export function EmojisStickers({ requestClose }: EmojisStickersProps) {
const { t } = useTranslation();
const [imagePack, setImagePack] = useState<ImagePack>();
const handleImagePackViewClose = () => {
setImagePack(undefined);
};
if (imagePack) {
return <ImagePackView address={imagePack.address} requestClose={handleImagePackViewClose} />;
}
return (
<SettingsPage title={t('Settings.emojis_stickers_title')} requestClose={requestClose}>
<UserPack onViewPack={setImagePack} />
<GlobalPacks onViewPack={setImagePack} />
</SettingsPage>
);
}

View file

@ -1,553 +0,0 @@
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Box,
Text,
Button,
Icon,
Icons,
IconButton,
Avatar,
AvatarImage,
AvatarFallback,
config,
Spinner,
Menu,
RectCords,
PopOut,
Checkbox,
toRem,
Scroll,
Header,
Line,
Chip,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useAtomValue } from 'jotai';
import { Room } from 'matrix-js-sdk';
import { useGlobalImagePacks, useRoomsImagePacks } from '../../../hooks/useImagePacks';
import { SectionLabel, SettingRow } from '../styles.css';
import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import {
EmoteRoomsContent,
ImagePack,
ImageUsage,
PackAddress,
packAddressEqual,
} from '../../../plugins/custom-emoji';
import { LineClamp2 } from '../../../styles/Text.css';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { stopPropagation } from '../../../utils/keyboard';
function GlobalPackSelector({
packs,
useAuthentication,
onSelect,
}: {
packs: ImagePack[];
useAuthentication: boolean;
onSelect: (addresses: PackAddress[]) => void;
}) {
const { t } = useTranslation();
const mx = useMatrixClient();
const roomToPacks = useMemo(() => {
const rToP = new Map<string, ImagePack[]>();
packs
.filter((pack) => !pack.deleted)
.forEach((pack) => {
if (!pack.address) return;
const pks = rToP.get(pack.address.roomId) ?? [];
pks.push(pack);
rToP.set(pack.address.roomId, pks);
});
return rToP;
}, [packs]);
const [selected, setSelected] = useState<PackAddress[]>([]);
const toggleSelect = (address: PackAddress) => {
setSelected((addresses) => {
const newAddresses = addresses.filter((addr) => !packAddressEqual(addr, address));
if (newAddresses.length !== addresses.length) {
return newAddresses;
}
newAddresses.push(address);
return newAddresses;
});
};
const addSelected = (adds: PackAddress[]) => {
setSelected((addresses) => {
const newAddresses = Array.from(addresses);
adds.forEach((address) => {
if (newAddresses.find((addr) => packAddressEqual(addr, address))) {
return;
}
newAddresses.push(address);
});
return newAddresses;
});
};
const removeSelected = (adds: PackAddress[]) => {
setSelected((addresses) => {
const newAddresses = addresses.filter(
(addr) => !adds.find((address) => packAddressEqual(addr, address))
);
return newAddresses;
});
};
const hasSelected = selected.length > 0;
return (
<Box grow="Yes" direction="Column">
<Header size="400" variant="Surface" style={{ padding: `0 ${config.space.S300}` }}>
<Box grow="Yes">
<Text size="L400" truncate>
{t('Settings.room_packs')}
</Text>
</Box>
<Box shrink="No">
<Chip
radii="Pill"
variant={hasSelected ? 'Success' : 'SurfaceVariant'}
outlined={hasSelected}
onClick={() => onSelect(selected)}
>
<Text size="B300">{hasSelected ? t('Settings.save') : t('Settings.close')}</Text>
</Chip>
</Box>
</Header>
<Line variant="Surface" size="300" />
<Box grow="Yes">
<Scroll size="300" hideTrack visibility="Hover">
<Box
direction="Column"
gap="400"
style={{
paddingLeft: config.space.S300,
paddingTop: config.space.S300,
paddingBottom: config.space.S300,
paddingRight: config.space.S100,
}}
>
{Array.from(roomToPacks.entries()).map(([roomId, roomPacks]) => {
const room = mx.getRoom(roomId);
if (!room) return null;
const roomPackAddresses = roomPacks
.map((pack) => pack.address)
.filter((addr): addr is PackAddress => addr !== undefined);
const allSelected = roomPackAddresses.every((addr) =>
selected.find((address) => packAddressEqual(addr, address))
);
return (
<Box key={roomId} direction="Column" gap="100">
<Box alignItems="Center">
<Box grow="Yes">
<Text size="L400">{room.name}</Text>
</Box>
<Box shrink="No">
<Chip
variant={allSelected ? 'Critical' : 'Surface'}
radii="Pill"
onClick={() => {
if (allSelected) {
removeSelected(roomPackAddresses);
return;
}
addSelected(roomPackAddresses);
}}
>
<Text size="B300">
{allSelected ? t('Settings.unselect_all') : t('Settings.select_all')}
</Text>
</Chip>
</Box>
</Box>
{roomPacks.map((pack) => {
const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon);
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication)
: undefined;
const { address } = pack;
if (!address) return null;
const added = !!selected.find((addr) => packAddressEqual(addr, address));
return (
<SequenceCard
key={pack.id}
className={SettingRow}
variant={added ? 'Success' : 'SurfaceVariant'}
direction="Column"
gap="400"
>
<SettingTile
title={pack.meta.name ?? t('Settings.unknown')}
description={<span className={LineClamp2}>{pack.meta.attribution}</span>}
before={
<Box alignItems="Center" gap="300">
<Avatar size="300" radii="300">
{avatarUrl ? (
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
) : (
<AvatarFallback>
<Icon size="400" src={Icons.Sticker} filled />
</AvatarFallback>
)}
</Avatar>
</Box>
}
after={
<Checkbox
checked={added}
variant="Success"
onClick={() => toggleSelect(address)}
/>
}
/>
</SequenceCard>
);
})}
</Box>
);
})}
{roomToPacks.size === 0 && (
<SequenceCard
className={SettingRow}
variant="Background"
direction="Column"
gap="400"
>
<Box
justifyContent="Center"
direction="Column"
gap="200"
style={{
padding: `${config.space.S700} ${config.space.S400}`,
maxWidth: toRem(300),
margin: 'auto',
}}
>
<Text size="H5" align="Center">
{t('Settings.no_packs')}
</Text>
<Text size="T200" align="Center">
{t('Settings.no_packs_desc')}
</Text>
</Box>
</SequenceCard>
)}
</Box>
</Scroll>
</Box>
</Box>
);
}
type GlobalPacksProps = {
onViewPack: (imagePack: ImagePack) => void;
};
export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const globalPacks = useGlobalImagePacks();
const [menuCords, setMenuCords] = useState<RectCords>();
const roomIds = useAtomValue(allRoomsAtom);
const rooms = useMemo(() => {
const rs: Room[] = [];
roomIds.forEach((rId) => {
const r = mx.getRoom(rId);
if (r) rs.push(r);
});
return rs;
}, [mx, roomIds]);
const roomsImagePack = useRoomsImagePacks(rooms);
const nonGlobalPacks = useMemo(
() =>
roomsImagePack.filter(
(pack) => !globalPacks.find((p) => packAddressEqual(pack.address, p.address))
),
[roomsImagePack, globalPacks]
);
const [selectedPacks, setSelectedPacks] = useState<PackAddress[]>([]);
const [removedPacks, setRemovedPacks] = useState<PackAddress[]>([]);
const unselectedGlobalPacks = useMemo(
() =>
nonGlobalPacks.filter(
(pack) => !selectedPacks.find((addr) => packAddressEqual(pack.address, addr))
),
[selectedPacks, nonGlobalPacks]
);
const handleRemove = (address: PackAddress) => {
setRemovedPacks((addresses) => [...addresses, address]);
};
const handleUndoRemove = (address: PackAddress) => {
setRemovedPacks((addresses) => addresses.filter((addr) => !packAddressEqual(addr, address)));
};
const handleSelected = (addresses: PackAddress[]) => {
setMenuCords(undefined);
if (addresses.length > 0) {
setSelectedPacks((a) => [...addresses, ...a]);
}
};
const [applyState, applyChanges] = useAsyncCallback(
useCallback(async () => {
const content =
mx.getAccountData(AccountDataEvent.PoniesEmoteRooms)?.getContent<EmoteRoomsContent>() ?? {};
const updatedContent: EmoteRoomsContent = JSON.parse(JSON.stringify(content));
selectedPacks.forEach((addr) => {
const roomsToState = updatedContent.rooms ?? {};
const stateKeyToObj = roomsToState[addr.roomId] ?? {};
stateKeyToObj[addr.stateKey] = {};
roomsToState[addr.roomId] = stateKeyToObj;
updatedContent.rooms = roomsToState;
});
removedPacks.forEach((addr) => {
if (updatedContent.rooms?.[addr.roomId]?.[addr.stateKey]) {
delete updatedContent.rooms?.[addr.roomId][addr.stateKey];
}
});
await mx.setAccountData(AccountDataEvent.PoniesEmoteRooms, updatedContent);
}, [mx, selectedPacks, removedPacks])
);
const resetChanges = useCallback(() => {
setSelectedPacks([]);
setRemovedPacks([]);
}, []);
useEffect(() => {
if (applyState.status === AsyncStatus.Success) {
resetChanges();
}
}, [applyState, resetChanges]);
const handleSelectMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const applyingChanges = applyState.status === AsyncStatus.Loading;
const hasChanges = removedPacks.length > 0 || selectedPacks.length > 0;
const renderPack = (pack: ImagePack) => {
const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon);
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
const { address } = pack;
if (!address) return null;
const removed = !!removedPacks.find((addr) => packAddressEqual(addr, address));
return (
<SequenceCard
key={pack.id}
className={SettingRow}
variant={removed ? 'Critical' : 'Background'}
outlined
mergeBorder={!removed}
direction="Column"
gap="400"
>
<SettingTile
title={
<span style={{ textDecoration: removed ? 'line-through' : undefined }}>
{pack.meta.name ?? t('Settings.unknown')}
</span>
}
description={<span className={LineClamp2}>{pack.meta.attribution}</span>}
before={
<Box alignItems="Center" gap="300">
{removed ? (
<IconButton
size="300"
radii="Pill"
variant="Critical"
onClick={() => handleUndoRemove(address)}
disabled={applyingChanges}
>
<Icon src={Icons.Plus} size="100" />
</IconButton>
) : (
<IconButton
size="300"
radii="Pill"
variant="Secondary"
onClick={() => handleRemove(address)}
disabled={applyingChanges}
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)}
<Avatar size="300" radii="300">
{avatarUrl ? (
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
) : (
<AvatarFallback>
<Icon size="400" src={Icons.Sticker} filled />
</AvatarFallback>
)}
</Avatar>
</Box>
}
after={
!removed && (
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
onClick={() => onViewPack(pack)}
>
<Text size="B300">{t('Settings.view')}</Text>
</Button>
)
}
/>
</SequenceCard>
);
};
return (
<>
<Box direction="Column" gap="200">
<Text as="span" className={SectionLabel}>
{t('Settings.favorite_packs')}
</Text>
<Box direction="Column">
<SequenceCard
className={SettingRow}
variant="Background"
outlined
mergeBorder
direction="Column"
gap="400"
>
<SettingTile
title={t('Settings.select_pack')}
description={t('Settings.select_pack_desc')}
after={
<>
<Button
onClick={handleSelectMenu}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">{t('Settings.select')}</Text>
</Button>
<PopOut
anchor={menuCords}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu
style={{
display: 'flex',
maxWidth: toRem(400),
width: '100vw',
maxHeight: toRem(500),
}}
>
<GlobalPackSelector
packs={unselectedGlobalPacks}
useAuthentication={useAuthentication}
onSelect={handleSelected}
/>
</Menu>
</FocusTrap>
}
/>
</>
}
/>
</SequenceCard>
{globalPacks.map(renderPack)}
{nonGlobalPacks
.filter((pack) => !!selectedPacks.find((addr) => packAddressEqual(pack.address, addr)))
.map(renderPack)}
</Box>
</Box>
{hasChanges && (
<Menu
style={{
position: 'sticky',
padding: config.space.S200,
paddingLeft: config.space.S400,
bottom: config.space.S400,
left: config.space.S400,
right: 0,
zIndex: 1,
}}
variant="Success"
>
<Box alignItems="Center" gap="400">
<Box grow="Yes" direction="Column">
{applyState.status === AsyncStatus.Error ? (
<Text size="T200">
<b>{t('Settings.apply_error')}</b>
</Text>
) : (
<Text size="T200">
<b>{t('Settings.apply_ready')}</b>
</Text>
)}
</Box>
<Box shrink="No" gap="200">
<Button
size="300"
variant="Success"
fill="None"
radii="300"
disabled={applyingChanges}
onClick={resetChanges}
>
<Text size="B300">{t('Settings.reset')}</Text>
</Button>
<Button
size="300"
variant="Success"
radii="300"
disabled={applyingChanges}
before={applyingChanges && <Spinner variant="Success" fill="Solid" size="100" />}
onClick={applyChanges}
>
<Text size="B300">{t('Settings.apply_changes')}</Text>
</Button>
</Box>
</Box>
</Menu>
)}
</>
);
}

View file

@ -1,64 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar, AvatarFallback, AvatarImage, Button, Icon, Icons, Text } from 'folds';
import { useUserImagePack } from '../../../hooks/useImagePacks';
import { SettingTile } from '../../../components/setting-tile';
import { SettingsSection } from '../SettingsSection';
import { ImagePack, ImageUsage } from '../../../plugins/custom-emoji';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
type UserPackProps = {
onViewPack: (imagePack: ImagePack) => void;
};
export function UserPack({ onViewPack }: UserPackProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const userPack = useUserImagePack();
const avatarMxc = userPack?.getAvatarUrl(ImageUsage.Emoticon);
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
const handleView = () => {
if (userPack) {
onViewPack(userPack);
} else {
const defaultPack = new ImagePack(mx.getSafeUserId(), {}, undefined);
onViewPack(defaultPack);
}
};
return (
<SettingsSection label={t('Settings.default_pack')}>
<SettingTile
title={userPack?.meta.name ?? t('Settings.unknown')}
description={userPack?.meta.attribution}
before={
<Avatar size="300" radii="300">
{avatarUrl ? (
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
) : (
<AvatarFallback>
<Icon size="400" src={Icons.Sticker} filled />
</AvatarFallback>
)}
</Avatar>
}
after={
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
onClick={handleView}
>
<Text size="B300">{t('Settings.view')}</Text>
</Button>
}
/>
</SettingsSection>
);
}

View file

@ -1 +0,0 @@
export * from './EmojisStickers';

View file

@ -1,4 +1,5 @@
import { style } from '@vanilla-extract/css';
import { globalStyle, style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { color, config, toRem } from 'folds';
// Section label above a grouped panel — uppercase, tracked, muted. Matches the
@ -92,3 +93,196 @@ export const SettingsContentCenter = style({
width: '100%',
margin: 'auto',
});
// ── Settings landing (profile hero + Fleet menu rows) ────────────────────
//
// The settings root opens on the user's OWN profile: a hero card with the
// avatar, display name and mono mxid at the top of the menu. Tapping it
// opens the Account page (where avatar/name editing lives) — the hero
// replaces the old «Account» menu row. Below it, the page list uses the
// Dawn sidebar vocabulary from the design bundle (stream-v2-dawn.jsx
// BOT_LIST / ChannelsList SectionIcon): tinted rounded-square glyph chips +
// app-type label + chevron, one accent per row.
export const ProfileHero = style({
appearance: 'none',
WebkitAppearance: 'none',
border: 'none',
width: '100%',
display: 'flex',
alignItems: 'center',
gap: config.space.S300,
padding: config.space.S300,
borderRadius: toRem(16),
cursor: 'pointer',
font: 'inherit',
textAlign: 'left',
color: color.SurfaceVariant.OnContainer,
backgroundColor: color.Surface.Container,
transition: 'background-color 120ms ease-out',
selectors: {
'&:active': { backgroundColor: color.Surface.ContainerActive },
'&[aria-pressed="true"]': { backgroundColor: color.Surface.ContainerActive },
},
});
// Mouse-only hover — Android WebView's synthesised sticky `:hover` would
// otherwise leave the hero tinted after every tap (same gate as
// ChannelsList.SectionHeader; see src/index.tsx input-mode detector).
globalStyle(`:root[data-input="mouse"] ${ProfileHero}:hover`, {
backgroundColor: color.Surface.ContainerHover,
});
export const ProfileHeroAvatar = style({
// `position: relative` is load-bearing: UserAvatar renders the folds
// AvatarImage/AvatarFallback, which are `position: absolute; width/height
// 100%` and size against the nearest POSITIONED ancestor — without this
// the image resolves against the settings panel and paints panel-sized
// over the menu (same contract as user-profile HeroAvatar).
position: 'relative',
flexShrink: 0,
width: toRem(56),
height: toRem(56),
borderRadius: '50%',
overflow: 'hidden',
display: 'flex',
});
export const ProfileHeroText = style({
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: toRem(2),
});
export const ProfileHeroName = style({
fontSize: toRem(16),
lineHeight: toRem(21),
fontWeight: 700,
color: color.SurfaceVariant.OnContainer,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
// Mono mxid line — same technical-metadata treatment as the chat header
// handle.
export const ProfileHeroHandle = style({
fontSize: toRem(12),
lineHeight: toRem(16),
fontFamily: 'var(--font-mono)',
color: color.SurfaceVariant.OnContainer,
opacity: 0.6,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
export const ProfileHeroChevron = style({
flexShrink: 0,
color: color.SurfaceVariant.OnContainer,
opacity: 0.45,
});
// Menu row — flat, tall enough to tap (44px+), active row raises on the
// panel tone with a weight bump (mirrors the chat-pane nav rows).
export const MenuRow = style({
appearance: 'none',
WebkitAppearance: 'none',
border: 'none',
background: 'transparent',
width: '100%',
minHeight: toRem(46),
display: 'flex',
alignItems: 'center',
gap: config.space.S300,
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: toRem(12),
cursor: 'pointer',
font: 'inherit',
textAlign: 'left',
color: color.SurfaceVariant.OnContainer,
transition: 'background-color 120ms ease-out',
selectors: {
'&:active': { backgroundColor: color.SurfaceVariant.ContainerActive },
'&[aria-pressed="true"]': { backgroundColor: color.SurfaceVariant.ContainerActive },
},
});
globalStyle(`:root[data-input="mouse"] ${MenuRow}:hover`, {
backgroundColor: color.SurfaceVariant.ContainerHover,
});
// Tinted rounded-square glyph chip — one muted Dawn accent per row (the
// design bundle's letter-avatar idiom; same vocabulary as
// ChannelsList.SectionIcon).
export const MenuRowIcon = recipe({
base: {
flexShrink: 0,
width: toRem(30),
height: toRem(30),
borderRadius: toRem(8),
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
},
variants: {
tint: {
violet: { background: 'rgba(149, 128, 255, 0.16)', color: '#9580ff' },
amber: { background: 'rgba(212, 184, 138, 0.18)', color: '#d4b88a' },
blue: { background: 'rgba(122, 182, 217, 0.16)', color: '#7ab6d9' },
green: { background: 'rgba(125, 211, 168, 0.16)', color: '#7dd3a8' },
rose: { background: 'rgba(192, 142, 123, 0.18)', color: '#c08e7b' },
neutral: {
background: color.Surface.Container,
color: color.SurfaceVariant.OnContainer,
},
critical: { background: color.Critical.Container, color: color.Critical.Main },
},
},
defaultVariants: { tint: 'neutral' },
});
export const MenuRowLabel = style({
flexGrow: 1,
minWidth: 0,
fontSize: toRem(14),
lineHeight: toRem(19),
fontWeight: 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
export const MenuRowChevron = style({
flexShrink: 0,
color: color.SurfaceVariant.OnContainer,
opacity: 0.4,
});
// Logout — same row shape in the critical accent, no chevron (it's an
// action, not a sub-page).
export const MenuRowCritical = style({
color: color.Critical.Main,
});
// Device row summary (Devices page) — the tappable name + last-activity
// column that toggles the technical details. Reset button so the whole
// text block is the toggle target.
export const DeviceSummary = style({
appearance: 'none',
WebkitAppearance: 'none',
border: 'none',
background: 'transparent',
padding: 0,
font: 'inherit',
textAlign: 'left',
color: 'inherit',
cursor: 'pointer',
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: toRem(2),
});

View file

@ -7,6 +7,7 @@ import {
SettingsNavEyebrow,
SettingsNavItem,
SettingsNavSection,
SettingsNavTint,
} from '../common-settings/SettingsNav';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@ -27,6 +28,7 @@ type SpaceSettingsMenuItem = {
page: SpaceSettingsPage;
name: string;
icon: IconSrc;
tint: SettingsNavTint;
};
const useSpaceSettingsMenuItems = (): SpaceSettingsMenuItem[] => {
@ -37,26 +39,31 @@ const useSpaceSettingsMenuItems = (): SpaceSettingsMenuItem[] => {
page: SpaceSettingsPage.GeneralPage,
name: t('RoomSettings.general'),
icon: Icons.Setting,
tint: 'violet',
},
{
page: SpaceSettingsPage.MembersPage,
name: t('RoomSettings.members'),
icon: Icons.User,
tint: 'blue',
},
{
page: SpaceSettingsPage.PermissionsPage,
name: t('RoomSettings.permissions'),
icon: Icons.Lock,
tint: 'amber',
},
{
page: SpaceSettingsPage.EmojisStickersPage,
name: t('RoomSettings.emojis_stickers'),
icon: Icons.Smile,
tint: 'rose',
},
{
page: SpaceSettingsPage.DeveloperToolsPage,
name: t('RoomSettings.developer_tools'),
icon: Icons.Terminal,
tint: 'neutral',
},
],
[t]
@ -144,6 +151,7 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
key={item.name}
icon={item.icon}
label={item.name}
tint={item.tint}
active={activePage === item.page}
onClick={() => setActivePage(item.page)}
/>

View file

@ -33,7 +33,10 @@ import {
useOpenChannelsWorkspaceSheet,
} from '../../../state/hooks/channelsWorkspaceSheet';
import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
import { mobileHorseshoeActiveAtom } from '../../../state/mobilePagerHeader';
import {
mobileHorseshoeActiveAtom,
mobileHorseshoeElevateHeaderAtom,
} from '../../../state/mobilePagerHeader';
import { VOJO_HORSESHOE_VOID_COLOR } from '../../../styles/horseshoe';
import { WorkspaceSwitcherSheet } from './WorkspaceSwitcherSheet';
import * as css from './ChannelsWorkspaceHorseshoe.css';
@ -168,15 +171,23 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
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. Mirror of the same
// bridge in `MobileSettingsHorseshoe.tsx`. See the atom's docs in
// `state/mobilePagerHeader.ts` for the «no black flash» rationale.
// pager-shared atoms. `active` feeds the refresh singleton's hide;
// `elevateHeader` makes `MobileTabsPagerHeader` z-elevate the static
// header from the first frame of drag — THIS sheet keeps the void-carve
// design with an opaque appBody, which would otherwise paint over the
// tabs band («no black flash» — see the atoms' docs in
// `state/mobilePagerHeader.ts`). The Settings sheet publishes only
// `active`: it overlays the header like a real curtain instead.
const setMobileHorseshoeActive = useSetAtom(mobileHorseshoeActiveAtom);
const setMobileHorseshoeElevateHeader = useSetAtom(mobileHorseshoeElevateHeaderAtom);
useEffect(() => {
setMobileHorseshoeActive(horseshoeActive);
return () => setMobileHorseshoeActive(false);
}, [horseshoeActive, setMobileHorseshoeActive]);
setMobileHorseshoeElevateHeader(horseshoeActive);
return () => {
setMobileHorseshoeActive(false);
setMobileHorseshoeElevateHeader(false);
};
}, [horseshoeActive, setMobileHorseshoeActive, setMobileHorseshoeElevateHeader]);
const handleRef = useRef<HTMLDivElement>(null);

View file

@ -133,20 +133,14 @@ export const curtainPinnedByTabAtom = atom<Partial<Record<MobilePagerTab, boolea
// switcher on Channels) is geometrically active — i.e. `expandedPx > 0`,
// which covers both the in-flight drag and the committed-open state.
//
// Source of truth for `MobileTabsPagerHeader`'s z-elevation: when this
// is true, the static pager header bumps to a positive z-index so it
// paints above the strip's stacking context and covers the safe-top +
// tabsRow band with `SurfaceVariant.Container`. Without this, the
// container's `VOJO_HORSESHOE_VOID_COLOR` paint that drives the carve
// would bleed up through the transparent strip stack into the system-
// tray strip.
// Consumed by `PagerRefreshSingleton`, which hides the strip-hosted
// mascot/refresh chrome while any sheet is up. Header z-elevation is NOT
// driven from here — that's `mobileHorseshoeElevateHeaderAtom` below,
// published only by the void-carve sheet design.
//
// Tracking the GEOMETRIC signal (not the sheet-open atoms) is load-
// bearing: container bg flips to the void colour the moment a drag-
// up on `DirectSelfRow` crosses 0 px, but the sheet atom only commits
// after 80 px past the threshold. Driving elevation off the geometry
// keeps the static header z-above the void from the first frame of
// drag — no «pane goes black then header re-paints light blue» flash.
// Tracking the GEOMETRIC signal (not the sheet-open atoms) keeps the
// singleton's hide in lockstep with the first frame of drag, not 80 px
// later when the user crosses the commit threshold.
//
// Mount exclusivity: in pager mode all three listing surfaces stay
// mounted, but only one's horseshoe can have `horseshoeActive=true`
@ -156,3 +150,15 @@ export const curtainPinnedByTabAtom = atom<Partial<Record<MobilePagerTab, boolea
// `true` at the same time is therefore unreachable through any user
// flow.
export const mobileHorseshoeActiveAtom = atom<boolean>(false);
// True while a horseshoe sheet whose design paints an OPAQUE appBody +
// void carve (the Channels workspace switcher) is active — only then must
// `MobileTabsPagerHeader` z-elevate the static header, or the void paint
// would swallow the tabs band («no black flash», see the atom above).
//
// The near-fullscreen Settings sheet deliberately does NOT publish this:
// its 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 — elevating the header there would paint the tabs on top
// of the sheet's drag handle and steal its taps.
export const mobileHorseshoeElevateHeaderAtom = atom<boolean>(false);