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;