+ {open && portalTarget
+ ? createPortal(
+
,
+ portalTarget
+ )
+ : null}
+
+ {children}
+
+
+
0 ? '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
+ // anchored to the top of the viewport — the sheet itself sits
+ // below the status bar, so that padding here would add dead
+ // space above the menu header and (because sub-page `
`
+ // doesn't have the same padding) misalign the sub-page title
+ // vs the menu title when the user navigates into a section.
+ // Same trick Modal500 uses for its centred dialog.
+ ['--vojo-safe-top' as string]: '0px',
+ }}
+ // `role="dialog"` alone marks this as a non-modal dialog: the
+ // assistive-tech announces it as a dialog, but interaction
+ // outside (DM list above, DirectSelfRow tap-through to a DM)
+ // remains permitted — matching the actual behaviour. We
+ // deliberately do NOT set `aria-modal="true"`: that attribute
+ // claims modality, but we have no focus trap and the wrapped
+ // app body is fully interactive. Per MDN, claiming
+ // `aria-modal="true"` without enforcing it misleads
+ // screen-reader users into thinking outside content is
+ // unreachable. The profile horseshoe takes the same stance
+ // (FocusTrap with `clickOutsideDeactivates: false`).
+ //
+ // `aria-label` (not `aria-labelledby`): the visible "Settings"
+ // title lives inside the PageNavHeader, which Settings.tsx
+ // unmounts when a mobile sub-page is active (so the labelledby
+ // target would become a dangling reference). A stable
+ // `aria-label` reads the same string regardless of which inner
+ // view is mounted.
+ role="dialog"
+ aria-label={t('Settings.title')}
+ >
+
+
+
+ {/* HorseshoeEnabledContext disabled for the embedded
+ Settings so its OWN PageRoot doesn't draw a second
+ 12px void column inside the mobile sheet. */}
+
+ {renderSettings && (
+ closeSheetRef.current()}
+ />
+ )}
+
+
+
+
+
+ );
+}
+
+// Top-level router. On non-mobile we pass through; the desktop /settings
+// route renders Settings in the nested-horseshoe right pane (see
+// `SettingsScreen.tsx`). The mobile branch owns the drag/atom state in
+// a sub-component so we don't run those hooks on desktop renders.
+export function MobileSettingsHorseshoe({
+ children,
+}: MobileSettingsHorseshoeProps): React.ReactElement {
+ const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
+ if (!isMobile) {
+ return children as React.ReactElement;
+ }
+ return