feat(horseshoe): split web page-nav and chat panel with 12px void gap and rounded inner corners on both sides

This commit is contained in:
heaven 2026-05-11 13:54:40 +03:00
parent 023a6a439c
commit 4d0b508ebb
5 changed files with 192 additions and 14 deletions

View file

@ -2,6 +2,7 @@ import React, { ReactNode } from 'react';
import FocusTrap from 'focus-trap-react';
import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
import { stopPropagation } from '../utils/keyboard';
import { HorseshoeEnabledContext } from './page';
type Modal500Props = {
requestClose: () => void;
@ -20,7 +21,14 @@ export function Modal500({ requestClose, children }: Modal500Props) {
}}
>
<Modal size="500" variant="Background">
{children}
{/* PageRoot rendered inside the dialog (Settings,
SpaceSettings, RoomSettings) would otherwise pick up
the web horseshoe layout void column + rounded
corners inside a fixed 500px shell. Disable horseshoe
for everything inside the modal. */}
<HorseshoeEnabledContext.Provider value={false}>
{children}
</HorseshoeEnabledContext.Provider>
</Modal>
</FocusTrap>
</OverlayCenter>

View file

@ -2,12 +2,14 @@ import React, {
ComponentProps,
MutableRefObject,
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { Box, Header, Line, Scroll, Text, as } from 'folds';
import { Box, Header, Line, Scroll, Text, as, toRem } from 'folds';
import { useAtom } from 'jotai';
import classNames from 'classnames';
import { ContainerColor } from '../../styles/ContainerColor.css';
@ -18,6 +20,27 @@ import {
clampSidebarWidth,
sidebarWidthAtom,
} from '../../state/sidebarWidth';
import {
VOJO_HORSESHOE_VOID_COLOR,
VOJO_HORSESHOE_GAP_PX,
VOJO_HORSESHOE_RADIUS_PX,
} from '../../styles/horseshoe';
import { isNativePlatform } from '../../utils/capacitor';
// Off-switch for the web horseshoe layout. Default `true` (horseshoe
// enabled on web non-mobile). Set to `false` by any container that
// embeds `PageRoot` outside the page level — currently `Modal500` for
// the settings dialogs (Settings / SpaceSettings / RoomSettings), so
// the modal's nav rail doesn't end up with a 12px void column and
// rounded corners inside a fixed 500px shell.
export const HorseshoeEnabledContext = createContext<boolean>(true);
const useHorseshoeEnabled = (): boolean => {
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
const contextEnabled = useContext(HorseshoeEnabledContext);
return contextEnabled && !isMobile && !isNativePlatform();
};
type PageRootProps = {
nav: ReactNode;
@ -26,13 +49,82 @@ type PageRootProps = {
export function PageRoot({ nav, children }: PageRootProps) {
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
// Horseshoe (gap + rounded chat wrapper) is web-only AND non-mobile-
// only AND not embedded (the `HorseshoeEnabledContext` lets parents
// like `Modal500` switch it off — settings dialogs render `PageRoot`
// inside a fixed-width modal where the void column doesn't belong).
// Native keeps the original single `<Line>` separator; mobile shows
// one panel at a time so no separator is needed at all.
const horseshoe = useHorseshoeEnabled();
const horseshoeRadius = toRem(VOJO_HORSESHOE_RADIUS_PX);
if (horseshoe) {
return (
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
{/* Page-nav slot paints two void squares at its top-right and
bottom-right via `background-image` (the
`linear-gradient(SAME, SAME)` idiom = a solid-colour image).
The wrapper's bg sits naturally beneath its in-flow
children, so PageNavHeader and the bottom row paint their
normal Background colour over the void everywhere except in
their own rounded TR / BR carves which expose the void.
No absolute positioning, no z-index gymnastics, and the
resize handle inside `ResizablePageNav` stays a sibling of
the clipped inner column so it isn't clipped. */}
<Box
shrink="No"
style={{
display: 'flex',
backgroundImage: `linear-gradient(${VOJO_HORSESHOE_VOID_COLOR}, ${VOJO_HORSESHOE_VOID_COLOR}), linear-gradient(${VOJO_HORSESHOE_VOID_COLOR}, ${VOJO_HORSESHOE_VOID_COLOR})`,
backgroundSize: `${horseshoeRadius} ${horseshoeRadius}, ${horseshoeRadius} ${horseshoeRadius}`,
backgroundPosition: 'top right, bottom right',
backgroundRepeat: 'no-repeat, no-repeat',
}}
>
{nav}
</Box>
<Box
shrink="No"
style={{
width: toRem(VOJO_HORSESHOE_GAP_PX),
backgroundColor: VOJO_HORSESHOE_VOID_COLOR,
}}
/>
{/* Right-side panel: two layers, both filling the same flex
slot. Outer paints the void colour; inner re-paints the
standard Background and clips its content with
`overflow:hidden + border-radius`, so the inner's rounded
TL/BL carves expose the outer's void. The explicit
Background bg on the inner is what keeps the panel's
apparent colour unchanged for routes whose content has no
opaque bg of its own (e.g. ChannelsLanding) without it
the outer void would bleed through. */}
<Box
grow="Yes"
style={{ minWidth: 0, backgroundColor: VOJO_HORSESHOE_VOID_COLOR }}
>
<Box
grow="Yes"
className={ContainerColor({ variant: 'Background' })}
style={{
minWidth: 0,
overflow: 'hidden',
borderTopLeftRadius: horseshoeRadius,
borderBottomLeftRadius: horseshoeRadius,
}}
>
{children}
</Box>
</Box>
</Box>
);
}
return (
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
{nav}
{screenSize !== ScreenSize.Mobile && (
<Line variant="Background" size="300" direction="Vertical" />
)}
{!isMobile && <Line variant="Background" size="300" direction="Vertical" />}
{children}
</Box>
);
@ -49,6 +141,7 @@ export function PageNav({
}: ClientDrawerLayoutProps & css.PageNavVariants) {
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
const horseshoe = useHorseshoeEnabled();
if (resizable && !isMobile) {
return <ResizablePageNav>{children}</ResizablePageNav>;
@ -60,7 +153,11 @@ export function PageNav({
className={css.PageNav({ size })}
shrink={isMobile ? 'Yes' : 'No'}
>
<Box grow="Yes" direction="Column">
<Box
grow="Yes"
direction="Column"
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
>
{children}
</Box>
</Box>
@ -70,6 +167,7 @@ export function PageNav({
function ResizablePageNav({ children }: { children: ReactNode }) {
const navRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const horseshoe = useHorseshoeEnabled();
const [savedWidth, setSavedWidth] = useAtom(sidebarWidthAtom);
const [vw, setVw] = useState<number>(
typeof window !== 'undefined' ? window.innerWidth : 1280
@ -182,7 +280,11 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
return (
<Box ref={navRef} className={css.PageNavResizable} style={{ width }}>
<Box grow="Yes" direction="Column">
<Box
grow="Yes"
direction="Column"
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
>
{children}
</Box>
{canResize && (
@ -196,6 +298,23 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
aria-label="Resize sidebar"
tabIndex={0}
className={css.PageNavResizeHandle}
// On web the page-nav is followed by the horseshoe void gap
// instead of the original `<Line>` separator — shift the
// handle so its hit-area fills exactly that void instead of
// bleeding onto the chat surface. Both dimensions are in
// `rem` to match the gap Box width (also `toRem(...)`), so
// they stay aligned across non-default root font-sizes.
// Native (or any context that disables the horseshoe, e.g.
// inside `Modal500`) keeps the CSS default which lines up
// with the Line.
style={
horseshoe
? {
right: `-${toRem(VOJO_HORSESHOE_GAP_PX)}`,
width: toRem(VOJO_HORSESHOE_GAP_PX),
}
: undefined
}
data-dragging={dragging || undefined}
data-at-min={atMin || undefined}
data-at-max={atMax || undefined}

View file

@ -1,6 +1,7 @@
import { style } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
export const PageNavResizable = style({
position: 'relative',
@ -12,6 +13,9 @@ export const PageNavResizeHandle = style({
position: 'absolute',
top: 0,
bottom: 0,
// Native default — sits across the (original) `<Line>` separator
// between page-nav and content. Web shifts the handle into the
// horseshoe void via an inline style override in `ResizablePageNav`.
right: -3,
width: 7,
cursor: 'col-resize',
@ -84,6 +88,24 @@ export const PageNav = recipe({
});
export type PageNavVariants = RecipeVariants<typeof PageNav>;
// Web-only horseshoe shell wrapping every page-nav's inner column.
// `overflow:hidden + border-radius` clips the nav's content into a
// shape with rounded TR and BR corners; the wrapper PageRoot puts
// behind the nav paints `#090909` at those same corners via background-
// image, so the carved area reads as the horseshoe void. An explicit
// `Background.Container` bg is required: folds `<Header>` is fully
// transparent (it only sets color/border), and some routes (Bots,
// Channels) don't paint anything at the bottom — without this bg the
// PageRoot void would bleed through the entire header / footer instead
// of just the carved corner. Applied conditionally in `PageNav` /
// `ResizablePageNav` below; native skips it entirely.
export const PageNavInnerWebHorseshoe = style({
overflow: 'hidden',
borderTopRightRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
borderBottomRightRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
backgroundColor: color.Background.Container,
});
export const PageNavHeader = recipe({
base: {
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,

View file

@ -1,13 +1,18 @@
import { style } from '@vanilla-extract/css';
import { toRem } from 'folds';
import {
VOJO_HORSESHOE_VOID_COLOR,
VOJO_HORSESHOE_GAP_PX,
VOJO_HORSESHOE_RADIUS_PX,
} from '../styles/horseshoe';
// Color of the «void» between the app shell and the bottom call
// rail — fixed in design, not theme-driven. Painted only when the
// call rail is mounted, so the rest of the app keeps its normal page
// background.
const SURFACE_GAP_COLOR = '#090909';
const HORSESHOE_RADIUS = toRem(32);
const HORSESHOE_GAP = toRem(12);
// Color of the «void» between the app shell and the bottom call rail —
// shared across every horseshoe surface, see `styles/horseshoe.ts`.
// Painted only when the call rail is mounted, so the rest of the app
// keeps its normal page background.
const SURFACE_GAP_COLOR = VOJO_HORSESHOE_VOID_COLOR;
const HORSESHOE_RADIUS = toRem(VOJO_HORSESHOE_RADIUS_PX);
const HORSESHOE_GAP = toRem(VOJO_HORSESHOE_GAP_PX);
// Outer flex column hosting app shell + bottom call rail.
// `min-height: 0` is required for nested scroll containers inside

View file

@ -0,0 +1,24 @@
// Shared constants for the Vojo "horseshoe" design language.
//
// The void colour is fixed in design and not theme-driven — the same
// #090909 is painted between every pair of horseshoe surfaces (top
// user-card silhouette, bottom call rail, settings/profile rail, etc.)
// so the seam reads as the same "near-black" no matter where it shows
// up. Originally introduced with the bottom call rail (commit 7054ca2 /
// `HorseshoeContainer.css.ts`); centralised here so consumers don't
// duplicate the literal and the value can be tuned in one place.
export const VOJO_HORSESHOE_VOID_COLOR = '#090909';
// Width (and height where vertical) of the void gap separating two
// horseshoe surfaces — kept identical between the bottom call rail
// and the page-nav <-> chat split so all seams in the app read with
// the same thickness. Pixels, intended for `toRem(...)` at the use
// site.
export const VOJO_HORSESHOE_GAP_PX = 12;
// Corner radius of every horseshoe surface — also pixels, applied via
// `toRem(...)` at the use site. Centralised so the page-nav corners,
// the chat-panel corners, and the bottom call-rail corners stay in
// lockstep, including the bg-image squares painted into those corners
// (which must match the radius exactly to avoid bleed-out).
export const VOJO_HORSESHOE_RADIUS_PX = 32;