From 4d0b508ebb92cf650d9508bcaaddddb444ce0610 Mon Sep 17 00:00:00 2001 From: heaven Date: Mon, 11 May 2026 13:54:40 +0300 Subject: [PATCH] feat(horseshoe): split web page-nav and chat panel with 12px void gap and rounded inner corners on both sides --- src/app/components/Modal500.tsx | 10 +- src/app/components/page/Page.tsx | 131 ++++++++++++++++++++++-- src/app/components/page/style.css.ts | 22 ++++ src/app/pages/HorseshoeContainer.css.ts | 19 ++-- src/app/styles/horseshoe.ts | 24 +++++ 5 files changed, 192 insertions(+), 14 deletions(-) create mode 100644 src/app/styles/horseshoe.ts diff --git a/src/app/components/Modal500.tsx b/src/app/components/Modal500.tsx index d421b625..a4c3128c 100644 --- a/src/app/components/Modal500.tsx +++ b/src/app/components/Modal500.tsx @@ -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) { }} > - {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. */} + + {children} + diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index 1a4dafe8..ad8d1d76 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -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(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 `` 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 ( + + {/* 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. */} + + {nav} + + + {/* 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. */} + + + {children} + + + + ); + } return ( {nav} - {screenSize !== ScreenSize.Mobile && ( - - )} + {!isMobile && } {children} ); @@ -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 {children}; @@ -60,7 +153,11 @@ export function PageNav({ className={css.PageNav({ size })} shrink={isMobile ? 'Yes' : 'No'} > - + {children} @@ -70,6 +167,7 @@ export function PageNav({ function ResizablePageNav({ children }: { children: ReactNode }) { const navRef = useRef(null); const handleRef = useRef(null); + const horseshoe = useHorseshoeEnabled(); const [savedWidth, setSavedWidth] = useAtom(sidebarWidthAtom); const [vw, setVw] = useState( typeof window !== 'undefined' ? window.innerWidth : 1280 @@ -182,7 +280,11 @@ function ResizablePageNav({ children }: { children: ReactNode }) { return ( - + {children} {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 `` 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} diff --git a/src/app/components/page/style.css.ts b/src/app/components/page/style.css.ts index 06dfe3c0..51821d87 100644 --- a/src/app/components/page/style.css.ts +++ b/src/app/components/page/style.css.ts @@ -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) `` 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; +// 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 `
` 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}`, diff --git a/src/app/pages/HorseshoeContainer.css.ts b/src/app/pages/HorseshoeContainer.css.ts index 51f52c78..08ed2fde 100644 --- a/src/app/pages/HorseshoeContainer.css.ts +++ b/src/app/pages/HorseshoeContainer.css.ts @@ -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 diff --git a/src/app/styles/horseshoe.ts b/src/app/styles/horseshoe.ts new file mode 100644 index 00000000..e11bc756 --- /dev/null +++ b/src/app/styles/horseshoe.ts @@ -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;