From 6097b704bb7f88520343f3ac4c19cbf8b60c3a91 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 30 Sep 2026 16:36:08 -0700 Subject: [PATCH] feat(web): Settings opens as a dialog with its left nav, no breadcrumbs (CL-9672) --- apps/web/src/app.tsx | 24 ++++-- apps/web/src/pages/settings-dialog.css | 80 +++++++++++++++++++ ...{settings-page.tsx => settings-dialog.tsx} | 47 ++++++----- apps/web/src/routes.tsx | 9 +-- apps/web/src/settings/styles.css | 19 ----- 5 files changed, 131 insertions(+), 48 deletions(-) create mode 100644 apps/web/src/pages/settings-dialog.css rename apps/web/src/pages/{settings-page.tsx => settings-dialog.tsx} (71%) diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 4a4c38d846..4c1cc9b583 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -6,7 +6,7 @@ import { Button, EmptyState } from "@corbits/react-ui"; import { WorkbenchLoadingState } from "@/chat"; import { QueryClientProvider } from "@tanstack/react-query"; import { BoldIconProvider, WarningCircle } from "@/lib/icons"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { AuthScreen } from "./auth-screen"; import { BenchProvider } from "./bench-context"; @@ -18,7 +18,8 @@ import { OnboardingPage } from "./pages/onboarding-page"; import { ProvisioningErrorPage } from "./pages/provisioning-error-page"; import { createAppQueryClient } from "./query-client"; import { Redirect } from "./redirect"; -import { APP_ROUTES, LOGIN_PATH, matchesRoute, ONBOARDING_PATH } from "./routes"; +import { SettingsDialog } from "./pages/settings-dialog"; +import { APP_ROUTES, LOGIN_PATH, matchesRoute, ONBOARDING_PATH, SETTINGS_PATH } from "./routes"; import type { SessionState, SessionUser } from "./session"; import { AppShell } from "./shell/app-shell"; import { ComposerInsertionProvider } from "./shell/composer-insertion"; @@ -62,7 +63,13 @@ function Shell({ // cache; wired to `onSignOut` so a session going invalid anywhere routes // the whole shell back to login, not just one stuck panel. const queryClient = useMemo(() => createAppQueryClient(onSignOut), [onSignOut]); - const route = APP_ROUTES.find((candidate) => matchesRoute(candidate.path, path)); + // Settings is a dialog over the last page, so that page stays mounted + // behind it; a cold load of /settings falls back to /new. + const settingsOpen = matchesRoute(SETTINGS_PATH, path); + const [backdropPath, setBackdropPath] = useState(settingsOpen ? "/new" : path); + if (!settingsOpen && backdropPath !== path) setBackdropPath(path); + const pagePath = settingsOpen ? backdropPath : path; + const route = APP_ROUTES.find((candidate) => matchesRoute(candidate.path, pagePath)); return ( @@ -70,9 +77,16 @@ function Shell({ - - {route === undefined ? : route.render(path, navigate)} + + {route === undefined ? : route.render(pagePath, navigate)} + {settingsOpen ? ( + navigate(backdropPath)} + /> + ) : null} diff --git a/apps/web/src/pages/settings-dialog.css b/apps/web/src/pages/settings-dialog.css new file mode 100644 index 0000000000..50b42df71a --- /dev/null +++ b/apps/web/src/pages/settings-dialog.css @@ -0,0 +1,80 @@ +/* Settings as a dialog: raised card, left section nav + one section body. */ +[data-slot="dialog-content"].settings-dialog { + display: flex; + flex-direction: column; + gap: 0; + width: min(calc(100vw - 2rem), 960px); + max-width: none; + height: 80vh; + max-height: none; + padding: 0; + border: 0; + border-radius: 12px; + background: var(--card, var(--popover)); + box-shadow: var(--raised); +} + +.settings-dialog-head { + padding: 14px 56px 12px 20px; + border-bottom: 1px solid var(--border); +} + +.settings-dialog-title { + font-size: 16px; + font-weight: 800; + letter-spacing: -0.01em; +} + +.settings-dialog-layout { + flex: 1; + min-height: 0; +} + +.settings-dialog-body { + flex: 1; + min-width: 0; + padding: 20px; +} + +@media (max-width: 640px) { + [data-slot="dialog-content"].settings-dialog { + width: 100vw; + height: 100dvh; + border-radius: 0; + top: 0; + left: 0; + translate: none; + max-width: none; + } + + .settings-dialog-layout { + flex-direction: column; + } + + .settings-dialog-layout .settings-nav { + width: 100%; + flex-direction: row; + flex-shrink: 0; + gap: 0.5rem; + padding: 0.5rem; + border-right: 0; + border-bottom: 1px solid var(--border); + overflow: auto hidden; + } + + .settings-dialog-layout .settings-nav-group { + flex-direction: row; + flex-shrink: 0; + gap: 0.25rem; + } + + .settings-dialog-layout .settings-nav [data-slot="sidebar-item-row"] { + flex-shrink: 0; + white-space: nowrap; + } + + .settings-dialog-layout .settings-nav-heading, + .settings-dialog-layout .settings-nav-advanced { + display: none; + } +} diff --git a/apps/web/src/pages/settings-page.tsx b/apps/web/src/pages/settings-dialog.tsx similarity index 71% rename from apps/web/src/pages/settings-page.tsx rename to apps/web/src/pages/settings-dialog.tsx index 9e1d473043..47fbcc3a3c 100644 --- a/apps/web/src/pages/settings-page.tsx +++ b/apps/web/src/pages/settings-dialog.tsx @@ -1,8 +1,14 @@ -// Thin mount: this file only adapts the app's bench-selection state and -// the URL into the shape `@/settings`'s shell expects. +// Thin mount: adapts the bench-selection state and the URL into the shape +// `@/settings`'s shell expects, and presents it over whatever page is behind. import { flattenSettingsSections, resolveActiveSection, SettingsShell } from "@/settings"; -import { PageShell } from "@corbits/react-ui"; +import { + Dialog, + DialogBody, + DialogContent, + DialogHeader, + DialogTitle, +} from "@corbits/react-ui/ui/dialog"; import { useBench } from "../bench-context"; import { useSignOut } from "../navigation"; @@ -14,15 +20,17 @@ import { } from "../path-ids"; import { resolveAppSettingsSectionGroups } from "../settings-groups"; import { SettingsNav } from "./settings-nav"; -import { StageTopBar } from "../shell/stage-top-bar"; +import "./settings-dialog.css"; import { useSettingsAccess } from "../settings-access"; -export function SettingsRoute({ +export function SettingsDialog({ path, navigate, + onClose, }: { readonly path: string; readonly navigate: (to: string) => void; + readonly onClose: () => void; }) { const { selectedTenantId, selectedPrincipalId } = useBench(); const onSignOut = useSignOut(); @@ -58,16 +66,19 @@ export function SettingsRoute({ } return ( -
- -
-

Settings

-

Personal and shared, side by side.

-
-
- -
- + { + if (!next) onClose(); + }} + > + + + Settings + +
+ + - +
-
-
+ + ); } diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 43d6465bc6..b41f1adb04 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -59,9 +59,6 @@ const ToolsRoute = lazy(async () => ({ const InsightsRoute = lazy(async () => ({ default: (await import("./pages/insights-page")).InsightsRoute, })); -const SettingsRoute = lazy(async () => ({ - default: (await import("./pages/settings-page")).SettingsRoute, -})); const SkillDetailRoute = lazy(async () => ({ default: (await import("./pages/skill-detail-page")).SkillDetailRoute, })); @@ -259,12 +256,12 @@ export const APP_ROUTES: readonly AppRoute[] = [ render: (path: string) => , }, { + // Presented as a dialog over the page behind it by `Shell` (app.tsx); the + // route exists so the palette and path matching still know Settings. path: SETTINGS_PATH, label: "Settings", icon: , - render: (path: string, navigate: (to: string) => void) => ( - - ), + render: () => <>, }, ]; diff --git a/apps/web/src/settings/styles.css b/apps/web/src/settings/styles.css index 21d3d93b3f..dffe57f23b 100644 --- a/apps/web/src/settings/styles.css +++ b/apps/web/src/settings/styles.css @@ -748,28 +748,9 @@ color: var(--ink-2); } -.settings-page-head { - flex-shrink: 0; - padding: 24px 28px 16px; -} - /* react-ui's SettingsPanel card: ring-as-border like the mockup's raised cards. */ .settings-stage-body > section.border { border-color: transparent; border-radius: 12px; box-shadow: var(--raised); } - -.settings-page-head h1 { - margin: 0; - font-size: 26px; - font-weight: 800; - letter-spacing: -0.02em; - line-height: 1.2; -} - -.settings-page-head p { - margin: 6px 0 0; - color: var(--ink-2); - font-size: 15px; -}