From f71b0247dafba7d266dcc3d0f8e4dcffdfbbbda3 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:15:39 +0100 Subject: [PATCH] Quick access bar and old school sidebars (#7695) --- .../public/locales/en-US/translation.toml | 19 +- frontend/editor/src/core/App.tsx | 28 +-- .../fileManager/FileSourceButtons.tsx | 2 +- .../components/filesPage/FolderTreePanel.tsx | 2 +- .../filesPage/filesPageReturnRoute.ts | 2 +- .../src/core/components/layout/AppFrame.css | 18 ++ .../src/core/components/layout/AppFrame.tsx | 22 ++ .../core/components/layout/NoAppChrome.tsx | 8 + .../components/layout/Workbench.module.css | 6 +- .../src/core/components/layout/Workbench.tsx | 30 ++- .../core/components/layout/WorkspaceFrame.css | 16 ++ .../notifications/NotificationBell.css | 8 +- .../notifications/NotificationBell.tsx | 119 ++--------- .../notifications/NotificationPanel.tsx | 135 ++++++++++++ .../src/core/components/shared/AppSwitch.tsx | 7 +- .../core/components/shared/AppSwitcher.tsx | 22 -- .../core/components/shared/BrandSwitcher.css | 15 -- .../shared/BrandSwitcher.stories.tsx | 16 -- .../core/components/shared/BrandSwitcher.tsx | 57 ----- .../src/core/components/shared/BrandTile.tsx | 29 +++ .../core/components/shared/FileSidebar.css | 25 ++- .../core/components/shared/FileSidebar.tsx | 60 +++--- .../core/components/shared/SidebarHeader.tsx | 33 +++ .../components/shared/SidebarToggleButton.tsx | 36 ++++ .../src/core/components/shared/Tooltip.tsx | 47 ++-- .../core/components/shared/WorkbenchBar.css | 22 +- .../core/components/shared/WorkbenchBar.tsx | 20 +- .../components/shared/navFooter/NavFooter.tsx | 87 ++++---- .../shared/quickNav/QuickNavBrand.tsx | 28 +++ .../shared/quickNav/QuickNavHostBridge.tsx | 85 ++++++++ .../shared/quickNav/QuickNavRail.css | 173 +++++++++++++++ .../shared/quickNav/QuickNavRailAccount.css | 29 +++ .../shared/quickNav/QuickNavRailAccount.tsx | 45 ++++ .../shared/quickNav/QuickNavRailBase.test.tsx | 123 +++++++++++ .../shared/quickNav/QuickNavRailBase.tsx | 101 +++++++++ .../shared/quickNav/QuickNavRailContainer.css | 38 ++++ .../shared/quickNav/QuickNavRailContainer.tsx | 83 ++++++++ .../shared/quickNav/QuickNavRailHost.tsx | 178 ++++++++++++++++ .../QuickNavRailNotifications.test.tsx | 88 ++++++++ .../quickNav/QuickNavRailNotifications.tsx | 51 +++++ .../quickNav/useQuickNavToolReasons.test.tsx | 139 ++++++++++++ .../shared/quickNav/useQuickNavToolReasons.ts | 131 ++++++++++++ .../core/components/tools/RightSidebar.tsx | 7 +- .../src/core/components/tools/ToolPanel.css | 8 +- .../viewer/useViewerWorkbenchBarButtons.tsx | 55 ++--- .../contexts/QuickNavHostContext.test.tsx | 128 +++++++++++ .../src/core/contexts/QuickNavHostContext.tsx | 201 ++++++++++++++++++ .../src/core/contexts/SidebarContext.tsx | 5 + .../src/core/contexts/ToolWorkflowContext.tsx | 2 +- frontend/editor/src/core/pages/HomePage.tsx | 191 ++++++++++++++--- frontend/editor/src/core/routes/hasPortal.ts | 2 + .../live/viewer-sidebar-add-buttons.spec.ts | 3 +- .../viewer-sidebar-add-buttons.spec.ts | 3 +- .../stubbed/workbench-session-restore.spec.ts | 10 +- frontend/editor/src/core/theme/colors.css | 2 +- frontend/editor/src/core/theme/dimensions.css | 7 +- frontend/editor/src/core/ui/NavSurface.tsx | 4 +- .../src/core/utils/homePageNavigation.ts | 9 +- .../src/core/utils/pendingReaderMode.ts | 13 ++ .../src/core/utils/viewTransition.test.ts | 63 ++++++ .../editor/src/core/utils/viewTransition.ts | 17 +- .../desktop/components/shared/AppSwitcher.tsx | 18 -- .../editor/src/desktop/routes/hasPortal.ts | 2 + .../editor/src/portal/components/AppShell.tsx | 22 +- .../portal/components/EditorStatusCard.tsx | 26 +-- .../src/portal/components/PortalSearchBar.css | 3 +- .../editor/src/portal/components/Sidebar.css | 105 +++++---- .../editor/src/portal/components/Sidebar.tsx | 47 +--- frontend/editor/src/proprietary/App.tsx | 80 ++++--- .../components/shared/AppSwitcher.tsx | 35 --- .../proprietary/data/processorEntitySearch.ts | 10 +- .../proprietary/data/processorSearchIndex.ts | 10 +- .../editor/src/proprietary/routes/Landing.tsx | 4 + .../routes/adminRouteExtensions.tsx | 8 +- .../src/proprietary/routes/hasPortal.ts | 3 + frontend/editor/src/saas/App.tsx | 97 +++++---- 76 files changed, 2665 insertions(+), 718 deletions(-) create mode 100644 frontend/editor/src/core/components/layout/AppFrame.css create mode 100644 frontend/editor/src/core/components/layout/AppFrame.tsx create mode 100644 frontend/editor/src/core/components/layout/NoAppChrome.tsx create mode 100644 frontend/editor/src/core/components/layout/WorkspaceFrame.css create mode 100644 frontend/editor/src/core/components/notifications/NotificationPanel.tsx delete mode 100644 frontend/editor/src/core/components/shared/AppSwitcher.tsx delete mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.css delete mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx delete mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.tsx create mode 100644 frontend/editor/src/core/components/shared/BrandTile.tsx create mode 100644 frontend/editor/src/core/components/shared/SidebarHeader.tsx create mode 100644 frontend/editor/src/core/components/shared/SidebarToggleButton.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavBrand.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts create mode 100644 frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx create mode 100644 frontend/editor/src/core/contexts/QuickNavHostContext.tsx create mode 100644 frontend/editor/src/core/routes/hasPortal.ts create mode 100644 frontend/editor/src/core/utils/pendingReaderMode.ts create mode 100644 frontend/editor/src/core/utils/viewTransition.test.ts delete mode 100644 frontend/editor/src/desktop/components/shared/AppSwitcher.tsx create mode 100644 frontend/editor/src/desktop/routes/hasPortal.ts delete mode 100644 frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx create mode 100644 frontend/editor/src/proprietary/routes/hasPortal.ts diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 8271d66dfc..378157818c 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -4141,7 +4141,6 @@ mobileShort = "Mobile" mobileUpload = "Mobile Upload" mobileUploadNotAvailable = "Mobile upload not enabled" moreOptions = "More options" -myFiles = "My Files" nextFile = "Next file" noFiles = "No files available" noFilesFound = "No files found matching your search" @@ -4242,9 +4241,9 @@ duplicateFailed = "Could not duplicate file" expand = "Expand sidebar" googleDrive = "Google Drive" googleDriveDisabled = "Google Drive is not configured" -leaveMyFiles = "Leave My Files" +leaveMyFiles = "Leave File library" library = "PDF Library" -myFiles = "My Files" +myFiles = "File library" noFiles = "No files yet" openFileManager = "Browse all files & folders" openFromComputer = "Open from computer" @@ -4287,7 +4286,7 @@ addToWorkspaceCount = "Add {{count}} to workspace" allFiles = "All files" back = "Back" backToFolder = "Back to {{folder}}" -backToMyFiles = "Back to My Files" +backToMyFiles = "Back to File library" breadcrumbs = "Folder path" bulkActions = "Actions" cancel = "Cancel" @@ -4339,7 +4338,6 @@ localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to moveSkippedRemote_one = "{{count}} file couldn't be moved on the server (no permission or already deleted)." moveSkippedRemote_other = "{{count}} files couldn't be moved on the server (no permission or already deleted)." moveTo = "Move to…" -myFiles = "My Files" newFolder = "New folder" newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on." newFolderTabUnavailable = "Switch to All or Cloud to create folders." @@ -9173,7 +9171,6 @@ appEditor = "Editor" appProcessor = "Processor" linkAccount = "Link Stirling account" primaryNav = "Primary navigation" -switchApp = "Switch app" [portal.shell.topbar] closeNav = "Close navigation" @@ -9652,6 +9649,16 @@ automate = "Automate" config = "Config" files = "Files" +[quickNav] +editor = "Editor" +home = "Stirling" +invite = "Invite" +landmark = "Quick navigation" +noProcessorAccess = "Ask an admin for processor access" +notifications = "Notifications" +processor = "Processor" +reader = "Reader" + [read] tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse" diff --git a/frontend/editor/src/core/App.tsx b/frontend/editor/src/core/App.tsx index 81db0564b8..c51c1fb85d 100644 --- a/frontend/editor/src/core/App.tsx +++ b/frontend/editor/src/core/App.tsx @@ -1,6 +1,7 @@ import { Suspense, lazy } from "react"; import { Routes, Route } from "react-router-dom"; import { AppProviders } from "@app/components/AppProviders"; +import { AppFrame } from "@app/components/layout/AppFrame"; import { AppLayout } from "@app/components/AppLayout"; import { LoadingFallback } from "@app/components/shared/LoadingFallback"; import { ThemeProvider } from "@app/components/shared/ThemeProvider"; @@ -53,18 +54,21 @@ export default function App() { } /> - {/* All other routes need AppProviders for backend integration */} - - - - - - - } - /> + {/* The app, under a shared frame so the rail renders once outside it. */} + }> + {/* All other routes need AppProviders for backend integration */} + + + + + + + } + /> + ); diff --git a/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx b/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx index 6ac3e3c91e..20ebaa191e 100644 --- a/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx +++ b/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx @@ -173,7 +173,7 @@ const FileSourceButtons: React.FC = ({ mb="xs" style={{ paddingLeft: "1rem" }} > - {t("fileManager.myFiles", "My Files")} + {t("fileSidebar.myFiles", "File library")} {buttons} diff --git a/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx index eb7f19170c..b56915b757 100644 --- a/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx +++ b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx @@ -121,7 +121,7 @@ export function FolderTreePanel({ active }: FolderTreePanelProps) {
- {t("filesPage.myFiles", "My Files")} + {t("fileSidebar.myFiles", "File library")}
diff --git a/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts b/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts index 5e6292d62f..69a1bc1c53 100644 --- a/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts +++ b/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts @@ -1,6 +1,6 @@ /** * Stores the route the user came from when they open files into the - * workbench from My Files. Lets the workbench show a "Back to My Files" + * workbench from the file library. Lets the workbench show a "Back to File library" * affordance and return to the exact folder they were browsing. * * Persisted in sessionStorage so a hard reload keeps the return path diff --git a/frontend/editor/src/core/components/layout/AppFrame.css b/frontend/editor/src/core/components/layout/AppFrame.css new file mode 100644 index 0000000000..0cc20b30c6 --- /dev/null +++ b/frontend/editor/src/core/components/layout/AppFrame.css @@ -0,0 +1,18 @@ +/* ========== APP FRAME ========== */ +/* The rail's column, then whichever app is mounted, so a switch changes only the app. */ +.app-frame { + display: flex; + height: 100vh; + height: 100dvh; /* track mobile browser chrome */ + overflow: hidden; + background-color: var(--c-bg); +} + +/* min-width: 0 so the app shrinks instead of forcing the frame past the window. */ +.app-frame__content { + flex: 1; + min-width: 0; + height: 100%; +} + +/* The rail hides itself below the mobile breakpoint - see QuickNavRailContainer.css. */ diff --git a/frontend/editor/src/core/components/layout/AppFrame.tsx b/frontend/editor/src/core/components/layout/AppFrame.tsx new file mode 100644 index 0000000000..38fa92ecba --- /dev/null +++ b/frontend/editor/src/core/components/layout/AppFrame.tsx @@ -0,0 +1,22 @@ +import { Suspense } from "react"; +import { Outlet } from "react-router-dom"; +import { LoadingFallback } from "@app/components/shared/LoadingFallback"; +import { QuickNavHostProvider } from "@app/contexts/QuickNavHostContext"; +import { QuickNavRailHost } from "@app/components/shared/quickNav/QuickNavRailHost"; +import "@app/components/layout/AppFrame.css"; + +/** The rail renders once outside both apps; Suspense sits inside it, not above. */ +export function AppFrame() { + return ( + +
+ +
+ }> + + +
+
+
+ ); +} diff --git a/frontend/editor/src/core/components/layout/NoAppChrome.tsx b/frontend/editor/src/core/components/layout/NoAppChrome.tsx new file mode 100644 index 0000000000..04416c02f1 --- /dev/null +++ b/frontend/editor/src/core/components/layout/NoAppChrome.tsx @@ -0,0 +1,8 @@ +import { Outlet } from "react-router-dom"; +import { useSuppressQuickNavRail } from "@app/contexts/QuickNavHostContext"; + +/** Pages that aren't the app: inside the frame for its providers, but with no rail. */ +export function NoAppChrome() { + useSuppressQuickNavRail(); + return ; +} diff --git a/frontend/editor/src/core/components/layout/Workbench.module.css b/frontend/editor/src/core/components/layout/Workbench.module.css index dd2b4a12bd..22d6fdc43c 100644 --- a/frontend/editor/src/core/components/layout/Workbench.module.css +++ b/frontend/editor/src/core/components/layout/Workbench.module.css @@ -12,10 +12,8 @@ .workbenchBarReopenTab { position: absolute; top: 100%; - /* Right-align with the retract handle inside the bar: the bar's right - margin (--nav-gutter) + 1px border + 8px bar padding + the handle's own - 6px inset. */ - right: calc(var(--nav-gutter) + 15px); + /* Aligns with the retract handle: 8px bar padding plus its own 6px inset. */ + right: 14px; display: flex; align-items: center; justify-content: center; diff --git a/frontend/editor/src/core/components/layout/Workbench.tsx b/frontend/editor/src/core/components/layout/Workbench.tsx index 903c552fd2..0cac20e574 100644 --- a/frontend/editor/src/core/components/layout/Workbench.tsx +++ b/frontend/editor/src/core/components/layout/Workbench.tsx @@ -1,4 +1,4 @@ -import { useState, Suspense, lazy } from "react"; +import { useState, useEffect, useRef, Suspense, lazy } from "react"; import { useTranslation } from "react-i18next"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { Box, Loader, Center, Stack, Text } from "@mantine/core"; @@ -15,6 +15,7 @@ import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useSigningOverlay } from "@app/contexts/SigningOverlayContext"; import { useCookieConsent } from "@app/hooks/useCookieConsent"; +import { useIsPhone } from "@app/hooks/useIsMobile"; import styles from "@app/components/layout/Workbench.module.css"; import WorkbenchBar from "@app/components/shared/WorkbenchBar"; @@ -58,10 +59,13 @@ export default function Workbench() { setPageEditorFunctions, setSidebarsVisible, customWorkbenchViews, + readerMode, } = useToolWorkflow(); const { handleToolSelect } = useToolWorkflow(); const { overlay: signingOverlay } = useSigningOverlay(); + // Below this width the rail, and the bell it carries, is gone. + const isPhone = useIsPhone(); // Get navigation state - this is the source of truth const { selectedTool: selectedToolId } = useNavigationState(); @@ -92,8 +96,20 @@ export default function Workbench() { !isBaseWorkbench(currentView) || // Shared signing drives the viewer from the sidebar with no file in context. (currentView === "viewer" && !!signingOverlay?.file); - const showWorkbenchBar = topControlsAvailable && hasWorkbenchContent; - const showFloatingSearch = topControlsAvailable && !hasWorkbenchContent; + // Reading hides the bar; the rail's Reader entry is the way back. + const showWorkbenchBar = + topControlsAvailable && hasWorkbenchContent && !readerMode; + const showFloatingSearch = + topControlsAvailable && !hasWorkbenchContent && !readerMode; + + // On the transition, so reading sets the toolbar's start state without locking it. + const prevReaderModeRef = useRef(readerMode); + useEffect(() => { + if (readerMode !== prevReaderModeRef.current) { + setViewerToolbarCollapsed(readerMode); + prevReaderModeRef.current = readerMode; + } + }, [readerMode]); const handlePreviewClose = () => { setPreviewFile(null); @@ -126,7 +142,7 @@ export default function Workbench() { } } - // The "My Files" workbench is available regardless of whether files are + // The file-library workbench is available regardless of whether files are // currently loaded into the workbench - it lives on top of the IDB store. if (currentView === "myFiles") { return ; @@ -249,10 +265,8 @@ export default function Workbench() { data-tour="workbench" style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }} > - {/* The bell normally rides in the workbench bar. Wherever that bar is not shown - My Files, - an empty workbench, a custom view without top controls - it gets its own corner, rather - than those being the places a user cannot see that something of theirs failed. */} - {!showWorkbenchBar && ( + {/* Phone only: above that the rail carries the bell, and here no bar does. */} + {isPhone && !showWorkbenchBar && (
diff --git a/frontend/editor/src/core/components/layout/WorkspaceFrame.css b/frontend/editor/src/core/components/layout/WorkspaceFrame.css new file mode 100644 index 0000000000..05cabe95f9 --- /dev/null +++ b/frontend/editor/src/core/components/layout/WorkspaceFrame.css @@ -0,0 +1,16 @@ +/* ========== WORKSPACE FRAME ========== */ +/* Rail and sidebar side by side, full height. Shared by both apps. */ +.workspace-frame { + display: flex; + height: 100%; + flex-shrink: 0; + background-color: var(--c-bg); +} + +/* On mobile the sidebar is a fixed drawer, so the frame stops laying out. */ +@media (max-width: 48rem) { + .workspace-frame { + display: block; + height: auto; + } +} diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.css b/frontend/editor/src/core/components/notifications/NotificationBell.css index 9352f6bef8..46367a0b2b 100644 --- a/frontend/editor/src/core/components/notifications/NotificationBell.css +++ b/frontend/editor/src/core/components/notifications/NotificationBell.css @@ -10,7 +10,7 @@ position: relative; padding: var(--sp-2, 0.5rem); border: none; - border-radius: var(--radius-md, 0.375rem); + border-radius: var(--radius-md); background: transparent; color: var(--c-text-muted); cursor: pointer; @@ -48,6 +48,12 @@ box-shadow: 0 10px 30px rgb(0 0 0 / 25%); } +/* The rail's bell is at the foot of a full-height column, so its panel rises beside it. */ +.notification-bell__panel--rail { + inset-inline-start: calc(var(--nav-rail-w) + var(--nav-gutter)); + inset-block-end: var(--nav-gutter); +} + .notification-bell__heading { margin: 0 0 var(--sp-2, 0.5rem); font-size: 0.875rem; diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.tsx b/frontend/editor/src/core/components/notifications/NotificationBell.tsx index f2ac1e0a3a..def06eed8d 100644 --- a/frontend/editor/src/core/components/notifications/NotificationBell.tsx +++ b/frontend/editor/src/core/components/notifications/NotificationBell.tsx @@ -1,27 +1,15 @@ -import { - Fragment, - useEffect, - useId, - useLayoutEffect, - useRef, - useState, -} from "react"; +import { useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { BellIcon, Button } from "@app/ui"; -import DividerWithText from "@app/components/shared/DividerWithText"; import { useNotifications } from "@app/hooks/useNotifications"; import { useNotificationActions } from "@app/components/notifications/notificationActions"; -import { NotificationItem } from "@app/components/notifications/NotificationItem"; +import { NotificationPanel } from "@app/components/notifications/NotificationPanel"; import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable"; import "@app/components/notifications/NotificationBell.css"; -/** - * Renders whatever the server sends without knowing which subsystem produced it or what its actions - * mean, so a new source or failure kind needs no change here. In core because both shells mount it. - */ +/** For the narrow layouts where the rail, which carries the bell, is off screen. */ export function NotificationBell() { - // A build with no notifications API gets no bell at all, rather than one that polls a - // nonexistent endpoint forever to show nothing. + // No API means no bell at all, rather than one polling an endpoint that isn't there. const available = useNotificationsAvailable(); if (!available) return null; return ; @@ -29,14 +17,10 @@ export function NotificationBell() { function MountedNotificationBell() { const { t } = useTranslation(); - const { notifications, unreadCount, documentStateFor, markAllSeen } = - useNotifications(); + const { unreadCount } = useNotifications(); const registry = useNotificationActions(); const [open, setOpen] = useState(false); const container = useRef(null); - const headingId = useId(); - // Where the new ones stop, frozen when the panel opens (opening marks everything read). - const [firstSeenId, setFirstSeenId] = useState(null); // Viewport-fixed, because the workbench bar clips its own overflow. const [anchor, setAnchor] = useState<{ top: number; right: number } | null>( null, @@ -61,54 +45,17 @@ function MountedNotificationBell() { }; }, [open]); - // Opening marks them read, not closing: waiting would leave the badge lit while they read. - const toggle = () => { - setOpen((wasOpen) => { - if (!wasOpen) { - // Before marking, or there is nothing left to read. - setFirstSeenId(notifications[unreadCount]?.id ?? null); - markAllSeen(); - } - return !wasOpen; - }); - }; - - /** - * How many count as new. No boundary id means all of them were; one that has since left the list - * leaves nothing to divide on, so it reads as none rather than guessing at a row. - */ - const boundaryIndex = firstSeenId - ? notifications.findIndex((notification) => notification.id === firstSeenId) - : notifications.length; - const dividedAt = Math.max(0, boundaryIndex); - - useEffect(() => { - if (!open) return; - const closeOnOutside = (event: MouseEvent) => { - const target = event.target as HTMLElement; - if (!container.current?.contains(target)) setOpen(false); - }; - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); - }; - document.addEventListener("mousedown", closeOnOutside); - document.addEventListener("keydown", closeOnEscape); - return () => { - document.removeEventListener("mousedown", closeOnOutside); - document.removeEventListener("keydown", closeOnEscape); - }; - }, [open]); - return (
{open && ( -
setOpen(false)} + registry={registry} style={anchor ? { top: anchor.top, right: anchor.right } : undefined} - > -

- {t("notifications.title", "Notifications")} -

- - {notifications.length === 0 ? ( -

- {t("notifications.empty", "Nothing to report.")} -

- ) : ( -
    - {notifications.map((notification, index) => ( - - {index === 0 && dividedAt > 0 && ( -
  • - -
  • - )} - {/* Only with something on both sides: a lone "Earlier" over everything says - nothing the empty badge has not. */} - {index === dividedAt && dividedAt > 0 && ( -
  • - -
  • - )} - setOpen(false)} - /> -
    - ))} -
- )} -
+ /> )}
); diff --git a/frontend/editor/src/core/components/notifications/NotificationPanel.tsx b/frontend/editor/src/core/components/notifications/NotificationPanel.tsx new file mode 100644 index 0000000000..f3ff0c621e --- /dev/null +++ b/frontend/editor/src/core/components/notifications/NotificationPanel.tsx @@ -0,0 +1,135 @@ +import { Fragment, useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import DividerWithText from "@app/components/shared/DividerWithText"; +import { useNotifications } from "@app/hooks/useNotifications"; +import type { ClientActionRegistry } from "@app/components/notifications/notificationActions"; +import { NotificationItem } from "@app/components/notifications/NotificationItem"; +import "@app/components/notifications/NotificationBell.css"; + +/** Named so a trigger in another tree can point at it with aria-controls. */ +export const NOTIFICATIONS_PANEL_ID = "quick-nav-notifications-panel"; + +export interface NotificationPanelProps { + onClose: () => void; + id?: string; + /** Passed in: its document handover has to run whether the panel is open or not. */ + registry: ClientActionRegistry; + style?: React.CSSProperties; + className?: string; +} + +/** Mounted only while open, since mounting is what marks everything read. */ +export function NotificationPanel({ + onClose, + registry, + id, + style, + className, +}: NotificationPanelProps) { + const { t } = useTranslation(); + const { notifications, unreadCount, documentStateFor, markAllSeen } = + useNotifications(); + const panel = useRef(null); + const headingId = useId(); + // Frozen on open, since opening marks them all read. + const [firstSeenId, setFirstSeenId] = useState(null); + + // On mount, not on close: waiting leaves the badge lit while they read. + const marked = useRef(false); + useEffect(() => { + if (marked.current) return; + marked.current = true; + // Before marking, or there is nothing left to divide on. + setFirstSeenId(notifications[unreadCount]?.id ?? null); + markAllSeen(); + }, [notifications, unreadCount, markAllSeen]); + + // No boundary means all were new; one that has left the list means none. + const boundaryIndex = firstSeenId + ? notifications.findIndex((notification) => notification.id === firstSeenId) + : notifications.length; + const dividedAt = Math.max(0, boundaryIndex); + + // Focus goes back to the opener only if it is still inside the panel on close. + useEffect(() => { + const opener = document.activeElement as HTMLElement | null; + panel.current?.focus(); + return () => { + if (panel.current?.contains(document.activeElement)) opener?.focus(); + }; + }, []); + + useEffect(() => { + const closeOnOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (panel.current?.contains(target)) return; + // A trigger closes this itself; counting it as outside would reopen it. + if (target.closest?.("[data-notifications-trigger]")) return; + onClose(); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", closeOnOutside); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("mousedown", closeOnOutside); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [onClose]); + + return ( + + ); +} diff --git a/frontend/editor/src/core/components/shared/AppSwitch.tsx b/frontend/editor/src/core/components/shared/AppSwitch.tsx index 71d35ed5ad..fbbe85ccdb 100644 --- a/frontend/editor/src/core/components/shared/AppSwitch.tsx +++ b/frontend/editor/src/core/components/shared/AppSwitch.tsx @@ -11,12 +11,7 @@ interface AppSwitchMenuItemsProps { onSwitch: (app: AppSwitchTarget) => void; } -/** - * The editor / processor items for the app-switch menu. Rendered inside the - * BrandSwitcher's logo dropdown, which both apps use as their switcher. The - * mark is the shared , which recolours itself from the theme - * tokens, so no colour-scheme prop needs threading down here. - */ +/** The editor / processor items for an app-switch menu. */ export function AppSwitchMenuItems({ current, onSwitch, diff --git a/frontend/editor/src/core/components/shared/AppSwitcher.tsx b/frontend/editor/src/core/components/shared/AppSwitcher.tsx deleted file mode 100644 index aaf55148ef..0000000000 --- a/frontend/editor/src/core/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Logo } from "@app/ui/Logo"; - -export interface AppSwitcherProps { - /** Icon-only brand mark for the collapsed rail. */ - collapsed?: boolean; -} - -/** - * Sidebar brand header. Core has no admin portal to switch to, so it just - * shows the Stirling logo. Builds that bundle the portal (proprietary/saas) - * shadow this with a version whose logo doubles as the editor⇄processor - * switcher. - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - return ( - - ); -} diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.css b/frontend/editor/src/core/components/shared/BrandSwitcher.css deleted file mode 100644 index dc1659ea15..0000000000 --- a/frontend/editor/src/core/components/shared/BrandSwitcher.css +++ /dev/null @@ -1,15 +0,0 @@ -/* Logo + app-switch dropdown, shared between the editor and the processor. - The logo itself is the trigger (its mark morphs into a chevron on hover). */ -.sui-brand-switcher { - display: flex; - align-items: center; - flex: 1; - min-width: 0; -} - -/* Tighten the ghost-button padding so the lockup sits flush like a plain logo, - and negative-margin it back so the hover surface still extends past the text. */ -.sui-brand-switcher__trigger.sui-btn { - --button-padding-x: 0.375rem; - margin-inline: -0.375rem; -} diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx deleted file mode 100644 index 92deb518b2..0000000000 --- a/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; - -const meta: Meta = { - title: "Brand/BrandSwitcher", - component: BrandSwitcher, - parameters: { layout: "centered" }, - args: { current: "processor", onSwitch: () => {} }, - argTypes: { - current: { control: "inline-radio", options: ["editor", "processor"] }, - }, -}; -export default meta; -type Story = StoryObj; - -export const Playground: Story = {}; diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.tsx deleted file mode 100644 index 474173fee2..0000000000 --- a/frontend/editor/src/core/components/shared/BrandSwitcher.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button, Dropdown } from "@app/ui"; -import { Logo } from "@app/ui/Logo"; -import { BrandMark } from "@app/components/shared/BrandMark"; -import { - AppSwitchMenuItems, - type AppSwitchTarget, -} from "@app/components/shared/AppSwitch"; -import "@app/components/shared/BrandSwitcher.css"; - -interface BrandSwitcherProps { - /** The app this is rendered in (shown active in the menu). */ - current: AppSwitchTarget; - /** Called with the selected app (only for the non-current one). */ - onSwitch: (app: AppSwitchTarget) => void; - /** Icon-only: drop the wordmark, keep the morphing mark as the trigger. */ - collapsed?: boolean; - className?: string; -} - -/** - * Brand lockup that doubles as the editor⇄processor switcher. The whole logo - * is the dropdown trigger: on hover / focus / open the mark morphs into a - * downward chevron (see BrandMark), so no separate chevron button is needed. - * Shared so the editor and the processor present one identical header. - */ -export function BrandSwitcher({ - current, - onSwitch, - collapsed = false, - className, -}: BrandSwitcherProps) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - - return ( -
- - - - - - - - -
- ); -} diff --git a/frontend/editor/src/core/components/shared/BrandTile.tsx b/frontend/editor/src/core/components/shared/BrandTile.tsx new file mode 100644 index 0000000000..e8ccba6db8 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandTile.tsx @@ -0,0 +1,29 @@ +interface BrandTileProps { + /** CSS length. Omit to let the caller's CSS size it. */ + size?: string; + className?: string; +} + +/** The mark in a rounded square. Decorative: call sites carry the accessible name. */ +export function BrandTile({ size, className }: BrandTileProps) { + return ( + + + + + + ); +} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index 2347d9a2b2..76db9deab4 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -1,7 +1,9 @@ /* ========== FILE SIDEBAR ========== */ .file-sidebar { - background-color: var(--c-bg); + /* One solid panel, with a rule only on the workbench side, so it frames the document. */ + background-color: var(--c-surface); + border-inline-end: 1px solid var(--c-border-subtle); display: flex; flex-direction: column; height: 100%; @@ -37,12 +39,19 @@ gap: 0.5rem; } -/* ---- Brand header (logo / editor⇄processor switcher) ---- */ -.file-sidebar-brand { +/* Flattened here; two classes deep to beat .sui-nav-surface without relying on order. */ +.file-sidebar .sui-nav-surface { + background: transparent; + border: 0; + border-radius: 0; +} + +/* ---- Header row (wordmark + collapse toggle) ---- */ +.file-sidebar-header { display: flex; align-items: center; - min-height: 40px; - padding: 0 0.375rem; + min-height: var(--nav-header-h); + padding: 0 var(--nav-gutter); flex-shrink: 0; } @@ -51,9 +60,9 @@ flex-shrink: 0; } -.file-sidebar[data-collapsed="true"] .file-sidebar-brand { - flex-direction: column; - gap: 0.25rem; +/* Collapsed the row holds only the toggle, so centre it. */ +.file-sidebar[data-collapsed="true"] .file-sidebar-header { + justify-content: center; padding: 0; } .file-sidebar[data-collapsed="true"] .file-sidebar-collapse-toggle { diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index f9337bd880..1dcf4a7301 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -24,7 +24,6 @@ import { useViewer } from "@app/contexts/ViewerContext"; import { useFileHandler } from "@app/hooks/useFileHandler"; import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; import { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary"; -import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; import { useOpenPlan } from "@app/hooks/useOpenPlan"; import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; import { @@ -32,8 +31,7 @@ import { useIndexedDBRevision, } from "@app/contexts/IndexedDBContext"; import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons"; -import { AppSwitcher } from "@app/components/shared/AppSwitcher"; -import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; +import { SidebarHeader } from "@app/components/shared/SidebarHeader"; import type { StirlingFileStub } from "@app/types/fileContext"; import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import FolderSpecialIcon from "@mui/icons-material/FolderSpecial"; @@ -78,8 +76,9 @@ import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import "@app/components/shared/FileSidebar.css"; -const COLLAPSED_WIDTH = "3.5rem"; -const EXPANDED_WIDTH = "16.25rem"; // ~260px +// Shared with the processor sidebar via tokens, so the two cannot drift. +const COLLAPSED_WIDTH = "var(--sidebar-collapsed-w)"; +const EXPANDED_WIDTH = "var(--sidebar-w)"; // Inlined to avoid a circular import with WatchedFoldersRegistration. const WATCHED_FOLDER_VIEW_ID = "watchedFolder"; @@ -98,9 +97,11 @@ export interface FileSidebarProps { collapsed?: boolean; onToggleCollapse?: () => void; onOpenSettings?: () => void; - /** Accessible name override for the toggle button. */ + /** The quick nav rail owns the account control, so the footer drops its own row. */ + accountHoisted?: boolean; + /** Accessible name override for the collapse toggle. */ toggleAriaLabel?: string; - /** Icon override for the toggle button (e.g. back-arrow on /files). */ + /** Icon override for the collapse toggle (e.g. back-arrow on /files). */ toggleIcon?: React.ReactNode; /** Override the Open-from-computer handler (e.g. upload to /files folder). */ onUploadFiles?: (files: File[]) => void | Promise; @@ -155,11 +156,12 @@ const FileSidebar = forwardRef( collapsed = false, onToggleCollapse, onOpenSettings, + accountHoisted = false, + toggleAriaLabel, + toggleIcon, onUploadFiles, onPickGoogleDriveFiles, extraAction, - toggleAriaLabel, - toggleIcon, }, ref, ) { @@ -249,7 +251,6 @@ const FileSidebar = forwardRef( const { displayName, profilePictureUrl, isAnonymous } = useAccountIdentity(); const credits = useFreeCreditsSummary(); - const otherApp = useOtherAppSwitch(); const openPlan = useOpenPlan(); // Leaf files = user-visible files (excludes intermediate tool outputs) @@ -943,25 +944,12 @@ const FileSidebar = forwardRef(
)}
-
- - {onToggleCollapse && ( - onToggleCollapse()} - aria-label={ - toggleAriaLabel ?? - (collapsed - ? t("fileSidebar.expand", "Expand sidebar") - : t("fileSidebar.collapse", "Collapse sidebar")) - } - > - {toggleIcon ?? } - - )} -
+ {/* Box 1 — top controls (open / my files / cloud). No title. File search lives in the global super search (top bar), not here. */} @@ -984,7 +972,7 @@ const FileSidebar = forwardRef( {/* Tooltips only fire when collapsed - when expanded the visible text label below already identifies each row, so a tooltip would just flash a duplicate. Distinct icons (UploadFile for - "Open from computer" vs FolderOpen for "My Files") so the + "Open from computer" vs FolderOpen for "File library") so the collapsed rail isn't two identical folder icons either. */} ( onClick={() => { // "Open from computer" goes straight to the native OS file // picker. The full file manager (recent + drives + folders) - // is reachable via "My Files" below. + // is reachable via "File library" below. nativeFileInputRef.current?.click(); }} role="button" @@ -1080,7 +1068,7 @@ const FileSidebar = forwardRef( )} ( }} role="button" tabIndex={0} - aria-label={t("fileSidebar.myFiles", "My Files")} + aria-label={t("fileSidebar.myFiles", "File library")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); @@ -1105,7 +1093,7 @@ const FileSidebar = forwardRef( {!collapsed && ( - {t("fileSidebar.myFiles", "My Files")} + {t("fileSidebar.myFiles", "File library")} )}
@@ -1370,15 +1358,15 @@ const FileSidebar = forwardRef( {/* Getting-started checklist, floating above the footer (SaaS only). */} - {/* Box 3 — the shared footer: credits, app switch, account row. */} + {/* Box 3 — the shared footer: credits, plan, and the account row unless hoisted. */} diff --git a/frontend/editor/src/core/components/shared/SidebarHeader.tsx b/frontend/editor/src/core/components/shared/SidebarHeader.tsx new file mode 100644 index 0000000000..1a2befa074 --- /dev/null +++ b/frontend/editor/src/core/components/shared/SidebarHeader.tsx @@ -0,0 +1,33 @@ +import { Logo } from "@app/ui/Logo"; +import { SidebarToggleButton } from "@app/components/shared/SidebarToggleButton"; + +export interface SidebarHeaderProps { + collapsed?: boolean; + onToggleCollapse?: () => void; + toggleAriaLabel?: string; + toggleIcon?: React.ReactNode; + className?: string; +} + +/** The wordmark and the collapse toggle; the brand mark sits in the rail beside it. */ +export function SidebarHeader({ + collapsed, + onToggleCollapse, + toggleAriaLabel, + toggleIcon, + className, +}: SidebarHeaderProps) { + return ( +
+ {!collapsed && } + {onToggleCollapse && ( + + )} +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/SidebarToggleButton.tsx b/frontend/editor/src/core/components/shared/SidebarToggleButton.tsx new file mode 100644 index 0000000000..3a1345360b --- /dev/null +++ b/frontend/editor/src/core/components/shared/SidebarToggleButton.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from "react-i18next"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; + +export interface SidebarToggleButtonProps { + collapsed?: boolean; + onToggle: () => void; + ariaLabel?: string; + icon?: React.ReactNode; +} + +/** Opens and closes the sidebar; on /files the caller swaps in a back arrow. */ +export function SidebarToggleButton({ + collapsed, + onToggle, + ariaLabel, + icon, +}: SidebarToggleButtonProps) { + const { t } = useTranslation(); + return ( + onToggle()} + aria-label={ + ariaLabel ?? + (collapsed + ? t("fileSidebar.expand", "Expand sidebar") + : t("fileSidebar.collapse", "Collapse sidebar")) + } + > + {icon ?? } + + ); +} diff --git a/frontend/editor/src/core/components/shared/Tooltip.tsx b/frontend/editor/src/core/components/shared/Tooltip.tsx index 55c06a1533..b8128bf637 100644 --- a/frontend/editor/src/core/components/shared/Tooltip.tsx +++ b/frontend/editor/src/core/components/shared/Tooltip.tsx @@ -13,7 +13,7 @@ import { addEventListenerWithCleanup } from "@app/utils/genericUtils"; import { useTooltipPosition } from "@app/hooks/useTooltipPosition"; import { TooltipTip } from "@app/types/tips"; import { TooltipContent } from "@app/components/shared/tooltip/TooltipContent"; -import { useSidebarContext } from "@app/contexts/SidebarContext"; +import { useOptionalSidebarContext } from "@app/contexts/SidebarContext"; import { useLogoAssets } from "@app/hooks/useLogoAssets"; import styles from "@app/components/shared/tooltip/Tooltip.module.css"; import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; @@ -59,6 +59,29 @@ export interface TooltipProps { showCloseButton?: boolean; } +/** Split out so only tooltips with a header need the logo and the providers behind it. */ +function TooltipHeader({ + header, +}: { + header: NonNullable; +}) { + const { tooltipLogo } = useLogoAssets(); + return ( +
+
+ {header.logo || ( + Stirling PDF + )} +
+ {header.title} +
+ ); +} + export const Tooltip: React.FC = ({ sidebarTooltip = false, position, @@ -85,7 +108,6 @@ export const Tooltip: React.FC = ({ const { t } = useTranslation(); const [internalOpen, setInternalOpen] = useState(false); const [isPinned, setIsPinned] = useState(false); - const { tooltipLogo } = useLogoAssets(); const triggerRef = useRef(null); const tooltipRef = useRef(null); @@ -105,9 +127,9 @@ export const Tooltip: React.FC = ({ }, []); // Always call the hook unconditionally to satisfy React's rules of hooks. - // The context is only used when sidebarTooltip is true. - const sidebarContextValue = useSidebarContext(); - const sidebarContext = sidebarTooltip ? sidebarContextValue : null; + // Optional: the plain tooltip renders outside the provider. + const sidebarContextValue = useOptionalSidebarContext(); + const sidebarContext = sidebarTooltip ? (sidebarContextValue ?? null) : null; const isControlled = controlledOpen !== undefined; const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled; @@ -443,20 +465,7 @@ export const Tooltip: React.FC = ({ } /> )} - {header && ( -
-
- {header.logo || ( - Stirling PDF - )} -
- {header.title} -
- )} + {header && } - {/* Left: optional "Back to My Files" + view switcher */} + {/* Left: optional "Back to File library" + view switcher */}
{returnRoute && hasFiles && ( <> @@ -501,7 +503,7 @@ export default function WorkbenchBar({ : "filesPage.backToMyFiles", returnRoute.label ? `Back to ${returnRoute.label}` - : "Back to My Files", + : "Back to File library", { folder: returnRoute.label ?? "" }, )} leftSection={} @@ -511,7 +513,7 @@ export default function WorkbenchBar({ ? t("filesPage.backToFolder", "Back to {{folder}}", { folder: returnRoute.label, }) - : t("filesPage.backToMyFiles", "Back to My Files")} + : t("filesPage.backToMyFiles", "Back to File library")}
@@ -603,9 +605,13 @@ export default function WorkbenchBar({ enforcingProgress={enforcingProgress} /> )} - {/* Last in the globals, so it is the rightmost control. */} -
- + {isPhone && ( + <> + {/* Last in the globals, so it is the rightmost control. */} +
+ + + )}
); diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx index 373c91bccf..848c0cb4a5 100644 --- a/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx @@ -33,6 +33,8 @@ export interface NavFooterProps { otherApp?: NavFooterAppLink | null; /** Extra rows above the account row (the self-hosted link-account CTA). */ accountExtras?: ReactNode; + /** False where the rail owns the account control, so only one avatar is drawn. */ + showAccount?: boolean; /** Icon-rail state: labels collapse to tooltips. */ collapsed?: boolean; className?: string; @@ -69,6 +71,7 @@ export function NavFooter({ onOpenPlan, otherApp, accountExtras, + showAccount = true, collapsed = false, className, }: NavFooterProps) { @@ -144,49 +147,51 @@ export function NavFooter({ }); } - rows.push({ - key: "account", - node: ( - - - - ), - }); + {!collapsed && ( + + {displayName} + + )} + {onOpenSettings && !collapsed && ( + + + + )} + + + ), + }); + } + + if (rows.length === 0) return null; return ( void; +} + +export function QuickNavBrand({ onReturnHome }: QuickNavBrandProps) { + const { t } = useTranslation(); + const label = t("quickNav.home", "Stirling"); + + return ( +
+ + + +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx new file mode 100644 index 0000000000..9a9101a826 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx @@ -0,0 +1,85 @@ +import { useCallback, useMemo, useState } from "react"; +import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; +import { + NotificationPanel, + NOTIFICATIONS_PANEL_ID, +} from "@app/components/notifications/NotificationPanel"; +import { useNotificationActions } from "@app/components/notifications/notificationActions"; +import { useQuickNavToolReasons } from "@app/components/shared/quickNav/useQuickNavToolReasons"; +import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable"; +import { useSigningBadgeCount } from "@app/hooks/signing/useSigningBadgeCount"; +import { + useRegisterQuickNavHost, + type QuickNavToolReasons, +} from "@app/contexts/QuickNavHostContext"; +import type { ToolId } from "@app/types/toolId"; + +export interface QuickNavHostBridgeProps { + portalAccess?: boolean; + readerMode?: boolean; + onSetReaderMode?: (on: boolean) => void; + onOpenSettings: () => void; + requestNavigation?: (go: () => void) => void; + onGoToDefaultState?: () => void; + onSelectTool?: (toolId: ToolId) => void; + /** Merged over the reasons worked out here, for what only the app can see. */ + toolReasons?: QuickNavToolReasons; +} + +/** Registers with the rail what only the app can see, and owns the notifications panel. */ +export function QuickNavHostBridge({ + portalAccess = false, + readerMode = false, + onSetReaderMode, + onOpenSettings, + requestNavigation, + onSelectTool, + onGoToDefaultState, + toolReasons, +}: QuickNavHostBridgeProps) { + const { displayName, profilePictureUrl } = useAccountIdentity(); + const signingBadge = useSigningBadgeCount(); + const notificationsAvailable = useNotificationsAvailable(); + // Built even when closed: it carries a one-shot document pickup that would sit unclaimed. + const notificationActions = useNotificationActions(); + const endpointReasons = useQuickNavToolReasons(); + const mergedToolReasons = useMemo(() => { + // An empty map from the app is silence, not an answer. + const extra = + toolReasons && Object.keys(toolReasons).length > 0 ? toolReasons : null; + if (!endpointReasons && !extra) return undefined; + return { ...endpointReasons, ...extra }; + }, [endpointReasons, toolReasons]); + const [notificationsOpen, setNotificationsOpen] = useState(false); + const closeNotifications = useCallback(() => setNotificationsOpen(false), []); + + useRegisterQuickNavHost( + { + identity: { displayName, profilePictureUrl }, + signingBadge, + portalAccess, + readerMode, + notificationsOpen, + toolReasons: mergedToolReasons, + }, + { + openSettings: onOpenSettings, + requestNavigation, + selectTool: onSelectTool, + setReaderMode: onSetReaderMode, + goToDefaultState: onGoToDefaultState, + toggleNotifications: () => setNotificationsOpen((open) => !open), + }, + ); + + // Mounted only while open, so a closed panel never subscribes to the poll. + if (!notificationsAvailable || !notificationsOpen) return null; + return ( + + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css new file mode 100644 index 0000000000..4d6845f592 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css @@ -0,0 +1,173 @@ +/* ========== QUICK NAV RAIL ========== */ + +.quick-nav-rail { + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + width: 100%; +} + +.quick-nav-rail-group { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--quicknav-item-gap); + flex-shrink: 0; + width: 100%; +} + +/* The glyph stays at the sidebar's scale; the button fills the rail for hit area. */ +.quick-nav-rail-item { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 2.25rem; + padding: 0; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--c-text-subtle); + cursor: pointer; + transition: + background-color var(--motion-fast), + color var(--motion-fast); +} + +.quick-nav-rail-item svg, +.quick-nav-rail-item img { + width: 1.125rem; + height: 1.125rem; + color: inherit; + fill: currentColor; +} + +/* Taller than wide (71x79), so height drives the width. */ +.quick-nav-rail-item .sui-brandmark { + width: auto; + height: 1.125rem; +} + +.quick-nav-rail-item[aria-disabled="true"] .sui-brandmark, +.quick-nav-rail-item[aria-disabled="true"] svg[viewBox="0 0 256 256"] { + filter: grayscale(1); +} + +.quick-nav-rail-item:hover { + background: var(--c-hover); + color: var(--c-text); +} + +/* Opacity rather than a colour: there is no disabled-text token. */ +.quick-nav-rail-item[aria-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; +} +.quick-nav-rail-item[aria-disabled="true"]:hover { + background: transparent; + color: var(--c-primary); +} + +.quick-nav-rail-item:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: -0.125rem; +} + +.quick-nav-rail-badge { + position: absolute; + top: 0.125rem; + inset-inline-end: 0.125rem; + min-width: 0.875rem; + height: 0.875rem; + padding: 0 0.1875rem; + border-radius: var(--radius-pill); + /* The solid step: 9px numerals need the darker end of the ramp. */ + background: var(--c-danger-solid); + color: var(--c-text-on-primary); + font-size: 0.5625rem; + font-weight: var(--font-weight-semibold); + line-height: 0.875rem; + text-align: center; + font-variant-numeric: tabular-nums; + pointer-events: none; +} + +.quick-nav-rail-badge[data-tone="warning"] { + background: var(--c-warning-solid); +} + +/* Top margin only: the group below supplies the other half, centring the rule. */ +.quick-nav-rail-divider { + width: 100%; + height: 0; + margin: var(--quicknav-item-gap) 0 0; + border: 0; + border-top: 1px solid var(--c-border); +} + +.quick-nav-rail-footer { + margin-top: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--quicknav-item-gap); + width: 100%; + flex-shrink: 0; +} + +/* ---- Brand: one header row tall, so it lines up with the sidebar's wordmark ---- */ +.quick-nav-brand { + width: 100%; + height: var(--nav-header-h); + flex-shrink: 0; + /* The bar's inset supplies part of the gap; only the remainder is added here. */ + margin-bottom: calc(var(--quicknav-item-gap) - var(--quicknav-surface-pad)); +} + +.quick-nav-brand-button { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + border: none; + background: transparent; + padding: 0; + cursor: pointer; +} + +.quick-nav-brand-button:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: -0.125rem; + border-radius: var(--radius-md); +} + +/* The "on" state: a solid block with the glyph knocked out, hover owning the tints. */ +.quick-nav-rail-item[aria-current="true"], +.quick-nav-rail-item[aria-current="true"]:hover, +.quick-nav-rail-item[aria-pressed="true"], +.quick-nav-rail-item[aria-pressed="true"]:hover { + background: var(--c-text); + color: var(--c-surface); +} + +/* On a dark ground full ink is white, so mix the block back toward the surface. */ +[data-theme="dark"] .quick-nav-rail-item[aria-current="true"], +[data-theme="dark"] .quick-nav-rail-item[aria-current="true"]:hover, +[data-theme="dark"] .quick-nav-rail-item[aria-pressed="true"], +[data-theme="dark"] .quick-nav-rail-item[aria-pressed="true"]:hover, +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-current="true"], +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-current="true"]:hover, +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-pressed="true"], +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-pressed="true"]:hover, +[data-mantine-color-scheme="dark"] .quick-nav-rail-item[aria-current="true"], +[data-mantine-color-scheme="dark"] + .quick-nav-rail-item[aria-current="true"]:hover, +[data-mantine-color-scheme="dark"] .quick-nav-rail-item[aria-pressed="true"], +[data-mantine-color-scheme="dark"] + .quick-nav-rail-item[aria-pressed="true"]:hover { + background: color-mix(in srgb, var(--c-text) 80%, var(--c-surface)); + color: var(--c-surface); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css new file mode 100644 index 0000000000..c12a6a1f71 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css @@ -0,0 +1,29 @@ +/* The account control, pinned to the bottom of the bar. */ + +.quick-nav-rail-account { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); + flex-shrink: 0; + /* Further than the shortcut gap: a filled disc reads heavier than a line glyph. */ + margin-top: var(--space-2); + /* Matches the slack centring the brand mark leaves at the top. */ + padding-bottom: 0.3125rem; +} + +.quick-nav-rail-avatar-target { + display: inline-flex; +} + +/* Appearance comes from the shared Avatar; only the button reset is ours. */ +.quick-nav-rail-avatar { + border: none; + padding: 0; + user-select: none; +} + +.quick-nav-rail-avatar:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: 0.125rem; +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx new file mode 100644 index 0000000000..1404925de6 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import { Avatar } from "@app/ui/Avatar"; +import type { QuickNavIdentity } from "@app/contexts/QuickNavHostContext"; +import "@app/components/shared/quickNav/QuickNavRailAccount.css"; + +export interface QuickNavRailAccountProps { + onOpenSettings: () => void; + /** Null between apps; the disc still renders, so the bar keeps its shape. */ + identity: QuickNavIdentity | null; +} + +/** The avatar opens settings, so there is no separate gear beside it. */ +export function QuickNavRailAccount({ + onOpenSettings, + identity, +}: QuickNavRailAccountProps) { + const { t } = useTranslation(); + const displayName = + identity?.displayName ?? t("auth.displayName.user", "User"); + const profilePictureUrl = identity?.profilePictureUrl ?? null; + const label = `${displayName} — ${t("fileSidebar.openSettings", "Open settings")}`; + + return ( +
+ + {/* A span, not the Avatar: Tooltip binds by cloning its child. */} + + + + +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx new file mode 100644 index 0000000000..328f2d8f4e --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx @@ -0,0 +1,123 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { + QuickNavRailBase, + type QuickNavEntry, +} from "@app/components/shared/quickNav/QuickNavRailBase"; + +/** The rail needs no providers. */ +function withProviders(ui: React.ReactNode) { + return <>{ui}; +} + +function entry( + id: string, + overrides: Partial = {}, +): QuickNavEntry { + return { + id, + label: id, + icon: null, + onClick: () => {}, + ...overrides, + }; +} + +const PROCESSOR = entry("processor"); +const WITHIN = [entry("files"), entry("reader")]; + +function renderRail(groups: QuickNavEntry[][]) { + const { container } = render( + withProviders(), + ); + return { + labels: [...container.querySelectorAll(".quick-nav-rail-item")].map((b) => + b.getAttribute("aria-label"), + ), + dividers: container.querySelectorAll(".quick-nav-rail-divider").length, + }; +} + +describe("QuickNavRailBase — groups", () => { + it("divides one group from the next", () => { + const { labels, dividers } = renderRail([[PROCESSOR], WITHIN]); + + expect(labels).toEqual(["processor", "files", "reader"]); + expect(dividers).toBe(1); + }); + + it("drops an empty group, and the divider with it", () => { + const { labels, dividers } = renderRail([[], WITHIN]); + + expect(labels).toEqual(["files", "reader"]); + expect(dividers).toBe(0); + }); +}); + +describe("QuickNavRailBase — entry state", () => { + it("reports on/off for a toggle and nothing for the rest", () => { + // Nothing here is a view you occupy, so only a real toggle has state. + const { container } = render( + withProviders( + , + ), + ); + + const state = [...container.querySelectorAll(".quick-nav-rail-item")].map( + (b) => [b.getAttribute("aria-label"), b.getAttribute("aria-pressed")], + ); + expect(state).toEqual([ + ["processor", null], + ["reader", "true"], + ["files", null], + ]); + expect(container.querySelectorAll("[aria-current]")).toHaveLength(0); + }); + + it("keeps a disabled entry in the tab order so its reason stays reachable", () => { + // The tooltip carrying the reason is only reachable while it can be focused. + const { container } = render( + withProviders( + , + ), + ); + + const automate = container.querySelector('[aria-label="automate"]')!; + expect(automate.getAttribute("aria-disabled")).toBe("true"); + expect(automate.hasAttribute("disabled")).toBe(false); + }); + + it("keeps an unavailable entry rendered, disabled rather than dropped", () => { + // Slots must not appear and vanish as access resolves. + const { container } = render( + withProviders( + , + ), + ); + + const processor = container.querySelector('[aria-label="processor"]'); + expect(processor).not.toBeNull(); + expect(processor?.getAttribute("aria-disabled")).toBe("true"); + // aria-disabled, not the disabled attribute: it stays focusable for its tooltip. + expect(processor?.hasAttribute("disabled")).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx new file mode 100644 index 0000000000..5c0be45db0 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx @@ -0,0 +1,101 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import "@app/components/shared/quickNav/QuickNavRail.css"; + +export type QuickNavTarget = "reader" | "editor" | "files" | "processor"; + +export interface QuickNavEntry { + id: string; + label: string; + icon: ReactNode; + /** The app you are in, drawn with an edge bar. */ + current?: boolean; + /** Only for entries that toggle something; use `current` for the app you are in. */ + pressed?: boolean; + /** Inert, with `reason` as its tooltip. Entries are dimmed, never dropped. */ + disabled?: boolean; + reason?: string; + badge?: number; + /** Popup semantics for an entry whose panel is rendered in another tree. */ + expanded?: boolean; + controls?: string; + /** "danger" waits on the user; "warning" is awareness only. */ + badgeTone?: "danger" | "warning"; + onClick: () => void; +} + +export interface QuickNavRailBaseProps { + /** Divided by a rule; empty groups are dropped. */ + groups: QuickNavEntry[][]; + footer?: ReactNode; +} + +/** Exported so footer entries reuse it rather than a lookalike. */ +export function RailButton({ + label, + icon, + pressed, + disabled, + reason, + badge, + badgeTone = "danger", + current, + expanded, + controls, + onClick, +}: Omit) { + return ( + + + + ); +} + +export function QuickNavRailBase({ groups, footer }: QuickNavRailBaseProps) { + const { t } = useTranslation(); + const populated = groups.filter((entries) => entries.length > 0); + return ( + + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css new file mode 100644 index 0000000000..606c2c3831 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css @@ -0,0 +1,38 @@ +.quick-nav-rail-container { + /* On the column, so the parts outside the nav inherit them too. */ + --quicknav-item-gap: var(--space-3); + --quicknav-surface-pad: 0.375rem; + + width: var(--nav-rail-w); + height: 100%; + flex-shrink: 0; + box-sizing: border-box; + /* On the column, not the bar, so the fill covers the gutters too. */ + background-color: var(--c-surface); + border-inline-end: 1px solid var(--c-border-subtle); + display: flex; + flex-direction: column; + padding-block: var(--nav-gutter); + padding-inline: calc(var(--nav-gutter) / 2); +} + +/* Child selector to beat .sui-nav-surface, which would win on order. */ +.quick-nav-rail-container > .quick-nav-rail-surface { + background: transparent; + border: 0; + border-radius: 0; +} + +.quick-nav-rail-surface { + flex: 1; + min-height: 0; + /* No inline padding: the bar is one target wide and would squeeze the buttons. */ + padding: var(--quicknav-surface-pad) 0; +} + +/* Below this width the sidebar is an off-canvas drawer, and the rail is just noise. */ +@media (max-width: 48rem) { + .quick-nav-rail-container { + display: none; + } +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx new file mode 100644 index 0000000000..154700518e --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from "react-i18next"; +import { NavSurface } from "@app/ui/NavSurface"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { QuickNavBrand } from "@app/components/shared/quickNav/QuickNavBrand"; +import type { QuickNavIdentity } from "@app/contexts/QuickNavHostContext"; +import { + QuickNavRailBase, + RailButton, + type QuickNavRailBaseProps, +} from "@app/components/shared/quickNav/QuickNavRailBase"; +import { QuickNavRailAccount } from "@app/components/shared/quickNav/QuickNavRailAccount"; +import { QuickNavRailNotifications } from "@app/components/shared/quickNav/QuickNavRailNotifications"; +import "@app/components/shared/quickNav/QuickNavRailContainer.css"; + +export type { + QuickNavEntry, + QuickNavTarget, +} from "@app/components/shared/quickNav/QuickNavRailBase"; + +export interface QuickNavRailContainerProps extends Omit< + QuickNavRailBaseProps, + "footer" +> { + /** The rail owns the account control, so the sidebars drop their own row. */ + onOpenSettings?: () => void; + /** Omitted in builds with no processor to invite anyone into. */ + onInvite?: () => void; + onToggleNotifications?: () => void; + notificationsOpen?: boolean; + identity?: QuickNavIdentity | null; + onReturnHome: () => void; +} + +/** The fixed-width column the rail sits in. */ +export function QuickNavRailContainer({ + onOpenSettings, + onInvite, + onToggleNotifications, + notificationsOpen, + identity = null, + onReturnHome, + ...railProps +}: QuickNavRailContainerProps) { + const { t } = useTranslation(); + return ( +
+ + + + + {onInvite && ( + + } + onClick={onInvite} + /> + )} + {onOpenSettings && ( + + )} +
+ } + /> +
+
+ ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx new file mode 100644 index 0000000000..5c748b36cc --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx @@ -0,0 +1,178 @@ +import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate } from "react-router-dom"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { QuickNavRailContainer } from "@app/components/shared/quickNav/QuickNavRailContainer"; +import type { QuickNavEntry } from "@app/components/shared/quickNav/QuickNavRailBase"; +import type { ToolId } from "@app/types/toolId"; +import { useQuickNavHost } from "@app/contexts/QuickNavHostContext"; +import { requestReaderMode } from "@app/utils/pendingReaderMode"; +import { + saveEditorReturnPath, + takeEditorReturnPath, +} from "@app/services/workbenchSession"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { HAS_PORTAL } from "@app/routes/hasPortal"; + +const SIZE = "1.125rem"; + +/** Entries come from the URL, not either app's context, so the rail survives a switch. */ +export function QuickNavRailHost() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { pathname, search } = useLocation(); + const host = useQuickNavHost(); + + const appMounted = Boolean(host?.appMounted); + + const inPortal = pathname.startsWith(PORTAL_BASENAME); + + // Only the app knows its own default state. + const returnHome = () => { + const reset = host?.actions.current?.goToDefaultState; + if (reset) reset(); + else navigate(inPortal ? PORTAL_BASENAME : EDITOR_BASENAME); + }; + + // Guarded where the app supplies a guard, so leaving mid-edit still prompts. + const go = (to: string) => { + const guard = host?.actions.current?.requestNavigation; + if (guard) guard(() => navigate(to)); + else navigate(to); + }; + + // Through the app where possible: its route only selects a tool on a fresh mount. + const openTool = (toolId: ToolId, route: string) => { + const select = host?.actions.current?.selectTool; + if (select) select(toolId); + else go(route); + }; + + const unusable = (id: ToolId) => { + const reason = host?.toolReasons?.[id]; + return { disabled: Boolean(reason), reason }; + }; + + const apps: QuickNavEntry[] = [ + { + id: "processor", + label: t("quickNav.processor", "Processor"), + // Two literals, not a computed name: the offline icon bundle scans for `icon="..."`. + icon: inPortal ? ( + + ) : ( + + ), + current: inPortal, + disabled: HAS_PORTAL && !inPortal && !host?.portalAccess, + reason: + HAS_PORTAL && !inPortal && !host?.portalAccess + ? t("quickNav.noProcessorAccess", "Ask an admin for processor access") + : undefined, + onClick: () => { + if (inPortal) { + returnHome(); + return; + } + saveEditorReturnPath(pathname + search); + go(PORTAL_BASENAME); + }, + }, + { + id: "editor", + label: t("quickNav.editor", "Editor"), + icon: inPortal ? ( + + ) : ( + + ), + current: !inPortal, + onClick: () => { + if (!inPortal) { + returnHome(); + return; + } + // Back to where you left the editor, not its front door. + navigate(takeEditorReturnPath() ?? EDITOR_BASENAME); + }, + }, + ]; + + const within: QuickNavEntry[] = [ + { + id: "files", + label: t("fileSidebar.myFiles", "File library"), + icon: ( + + ), + onClick: () => go("/files"), + }, + { + id: "reader", + label: t("quickNav.reader", "Reader"), + icon: ( + + ), + pressed: Boolean(host?.readerMode), + // From the processor there is no editor to toggle - see pendingReaderMode. + onClick: () => { + const setMode = host?.actions.current?.setReaderMode; + if (setMode) { + setMode(!host?.readerMode); + return; + } + requestReaderMode(); + go(EDITOR_BASENAME); + }, + }, + { + id: "automate", + label: t("quickAccess.automate", "Automate"), + icon: ( + + ), + ...unusable("automate"), + onClick: () => openTool("automate", "/automate"), + }, + { + id: "sharedSign", + label: t("home.sharedSign.title", "Shared Signing"), + icon: ( + + ), + badge: host?.signingBadge, + badgeTone: "warning", + ...unusable("sharedSign"), + onClick: () => openTool("sharedSign", "/shared-sign"), + }, + ]; + + // Read at click time, so it's always the mounted app's. + const openSettings = () => host?.actions.current?.openSettings?.(); + + // A route that isn't the app hides the bar - see useSuppressQuickNavRail. + if (!appMounted || host?.chromeless) return null; + + return ( + go(`${PORTAL_BASENAME}/users`) + : undefined + } + onToggleNotifications={() => + host?.actions.current?.toggleNotifications?.() + } + notificationsOpen={host?.notificationsOpen} + /> + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx new file mode 100644 index 0000000000..800eb55b5b --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { AppNotification } from "@app/services/notifications"; +import { QuickNavRailNotifications } from "@app/components/shared/quickNav/QuickNavRailNotifications"; + +const fetchNotifications = vi.fn(); + +vi.mock("@app/services/notifications", () => ({ + fetchNotifications: (...args: unknown[]) => fetchNotifications(...args), +})); + +vi.mock("@app/services/localFilePresence", () => ({ + hasLocalFile: () => Promise.resolve(false), +})); + +const h = vi.hoisted(() => ({ notificationsAvailable: true })); + +vi.mock("@app/components/notifications/useNotificationsAvailable", () => ({ + useNotificationsAvailable: () => h.notificationsAvailable, +})); + +function notification(id: string): AppNotification { + return { + id, + kind: "PIPELINE_FAILED", + title: id, + createdAt: "2026-01-01T00:00:00Z", + fileId: null, + sourceId: null, + count: 1, + actions: [], + } as unknown as AppNotification; +} + +describe("QuickNavRailNotifications", () => { + beforeEach(() => { + window.localStorage.clear(); + fetchNotifications.mockReset().mockResolvedValue([]); + h.notificationsAvailable = true; + }); + + it("keeps out of a build with no notifications API, and off its timer", async () => { + // No endpoint to poll and nothing it could show. + h.notificationsAvailable = false; + + const { container } = render( + {}} />, + ); + + await Promise.resolve(); + expect(container.querySelector(".quick-nav-rail-item")).toBeNull(); + expect(fetchNotifications).not.toHaveBeenCalled(); + }); + + it("carries the unread count on the icon", async () => { + fetchNotifications.mockResolvedValue([ + notification("a"), + notification("b"), + ]); + + render( {}} />); + + expect(await screen.findByText("2")).toBeTruthy(); + }); + + it("asks the mounted app to open the panel rather than opening one itself", async () => { + const onToggle = vi.fn(); + const { container } = render( + , + ); + + await waitFor(() => expect(fetchNotifications).toHaveBeenCalled()); + fireEvent.click(container.querySelector(".quick-nav-rail-item")!); + + expect(onToggle).toHaveBeenCalledTimes(1); + // No panel of its own: a row's actions would have no workbench to act on. + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("stays pressable before an app has registered, doing nothing", async () => { + // Between apps there is briefly no handler. + const { container } = render(); + + await waitFor(() => expect(fetchNotifications).toHaveBeenCalled()); + const button = container.querySelector(".quick-nav-rail-item")!; + expect(() => fireEvent.click(button)).not.toThrow(); + }); +}); diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx new file mode 100644 index 0000000000..aee294de1c --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { RailButton } from "@app/components/shared/quickNav/QuickNavRailBase"; +import { useNotifications } from "@app/hooks/useNotifications"; +import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable"; +import { NOTIFICATIONS_PANEL_ID } from "@app/components/notifications/NotificationPanel"; + +export interface QuickNavRailNotificationsProps { + onToggle?: () => void; + /** Whether the app's panel is open, which this button reports but does not own. */ + open?: boolean; +} + +/** The count is read here; the app owns the panel - see NotificationPanel. */ +export function QuickNavRailNotifications({ + onToggle, + open = false, +}: QuickNavRailNotificationsProps) { + // Gated before the count is read: subscribing starts the poll. + const available = useNotificationsAvailable(); + if (!available) return null; + return ; +} + +function MountedRailNotifications({ + onToggle, + open, +}: QuickNavRailNotificationsProps) { + const { t } = useTranslation(); + const { unreadCount } = useNotifications(); + + return ( + // Read by the panel's outside-click handler; on a wrapper, RailButton's props being fixed. + + + } + badge={unreadCount} + expanded={Boolean(open)} + controls={NOTIFICATIONS_PANEL_ID} + onClick={() => onToggle?.()} + /> + + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx new file mode 100644 index 0000000000..6295ddde66 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useQuickNavToolReasons } from "@app/components/shared/quickNav/useQuickNavToolReasons"; + +const h = vi.hoisted(() => ({ + endpointStatus: {} as Record, + endpointDetails: {} as Record, + loading: false, + configLoading: false, + groupSigningEnabled: true, +})); + +vi.mock("@app/hooks/useEndpointConfig", () => ({ + useMultipleEndpointsEnabled: () => ({ + endpointStatus: h.endpointStatus, + endpointDetails: h.endpointDetails, + loading: h.loading, + error: null, + refetch: async () => {}, + }), +})); + +vi.mock("@app/contexts/AppConfigContext", () => ({ + useAppConfig: () => ({ + config: null, + loading: h.configLoading, + error: null, + refetch: async () => {}, + }), +})); + +vi.mock("@app/hooks/useGroupSigningEnabled", () => ({ + useGroupSigningEnabled: () => h.groupSigningEnabled, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + }), +})); + +describe("useQuickNavToolReasons", () => { + beforeEach(() => { + window.localStorage.clear(); + h.endpointStatus = {}; + h.endpointDetails = {}; + h.loading = false; + h.configLoading = false; + h.groupSigningEnabled = true; + }); + + it("admits it does not know rather than reporting nothing wrong", () => { + h.loading = true; + h.endpointStatus = { automate: false }; + + expect( + renderHook(() => useQuickNavToolReasons()).result.current, + ).toBeNull(); + }); + + it("reports what it last knew while the answer is being fetched again", () => { + // Each app has its own query cache, and a reload has none at all. + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "CONFIG" } }; + renderHook(() => useQuickNavToolReasons()); + + h.loading = true; + h.endpointStatus = {}; + h.endpointDetails = {}; + + const { result } = renderHook(() => useQuickNavToolReasons()); + expect(result.current?.automate).toBe("Disabled by server administrator"); + }); + + it("forgets a reason once the server stops reporting it", () => { + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "CONFIG" } }; + renderHook(() => useQuickNavToolReasons()); + + h.endpointStatus = { automate: true }; + h.endpointDetails = {}; + expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual( + {}, + ); + + // The cleared state, not the old reason, is what a reload reads back. + h.loading = true; + expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual( + {}, + ); + }); + + it("says nothing about an endpoint the server reports as available", () => { + h.endpointStatus = { automate: true }; + + expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual( + {}, + ); + }); + + it("blames the administrator when the endpoint was turned off by config", () => { + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "CONFIG" } }; + + const { result } = renderHook(() => useQuickNavToolReasons()); + // The tool picker's label with its trailing colon stripped. + expect(result.current?.automate).toBe("Disabled by server administrator"); + }); + + it("blames the missing dependency when that is what the server said", () => { + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "DEPENDENCY" } }; + + const { result } = renderHook(() => useQuickNavToolReasons()); + expect(result.current?.automate).toBe( + "Unavailable - required tool missing on server", + ); + }); + + it("greys out shared signing when the server has the feature switched off", () => { + // A whole feature rather than a removable endpoint, so it has its own signal. + h.groupSigningEnabled = false; + + const { result } = renderHook(() => useQuickNavToolReasons()); + expect(result.current?.sharedSign).toBe( + "Collaborative signing isn't enabled on this server", + ); + }); + + it("waits for the config before judging shared signing", () => { + // The config loads separately and reads as "off" before it arrives. + h.configLoading = true; + h.groupSigningEnabled = false; + + expect( + renderHook(() => useQuickNavToolReasons()).result.current, + ).toBeNull(); + }); +}); diff --git a/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts new file mode 100644 index 0000000000..235f4d4f08 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useGroupSigningEnabled } from "@app/hooks/useGroupSigningEnabled"; +import { getDisabledLabel } from "@app/components/tools/fullscreen/shared"; +import type { QuickNavToolReasons } from "@app/contexts/QuickNavHostContext"; +import type { ToolId } from "@app/types/toolId"; + +const ENTRY_ENDPOINTS = { + automate: ["automate"], +} satisfies Partial>; + +// Object.keys widens to string, which a tool-id-keyed record can't be indexed by. +const ENDPOINT_ENTRIES = Object.keys( + ENTRY_ENDPOINTS, +) as (keyof typeof ENTRY_ENDPOINTS)[]; + +/** Shared signing is a feature toggle rather than an endpoint, so it has its own cause. */ +type EndpointCause = "missingDependency" | "disabledByAdmin"; +type Cause = EndpointCause | "groupSigningOff"; +type Causes = Partial>; +const CAUSES: Cause[] = [ + "missingDependency", + "disabledByAdmin", + "groupSigningOff", +]; + +/** Causes, not sentences, so a language change can't resurrect stale text. */ +const STORAGE_KEY = "stirling.quickNav.toolCauses"; + +function readRemembered(): Causes | null { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + const known = Object.entries(parsed as Record).filter( + ([, cause]) => CAUSES.includes(cause as Cause), + ) as [ToolId, Cause][]; + return Object.fromEntries(known); + } catch { + return null; + } +} + +function remember(causes: Causes): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(causes)); + } catch { + // Won't survive the next reload. + } +} + +function causesFor( + endpointStatus: Record, + endpointDetails: Record, +): Causes { + const causes: Causes = {}; + for (const entry of ENDPOINT_ENTRIES) { + const off = ENTRY_ENDPOINTS[entry].filter( + (name) => endpointStatus[name] === false, + ); + if (off.length === 0) continue; + causes[entry] = off.some( + (name) => endpointDetails[name]?.reason === "DEPENDENCY", + ) + ? "missingDependency" + : "disabledByAdmin"; + } + return causes; +} + +/** Why a rail entry can't be used. Null means no answer yet, an empty map nothing wrong. */ +export function useQuickNavToolReasons(): QuickNavToolReasons | null { + const { t } = useTranslation(); + const endpoints = useMemo(() => Object.values(ENTRY_ENDPOINTS).flat(), []); + const { endpointStatus, endpointDetails, loading } = + useMultipleEndpointsEnabled(endpoints); + + // Read once: later reads would fight the live answer. + const [remembered] = useState(readRemembered); + + const { loading: configLoading } = useAppConfig(); + const groupSigningEnabled = useGroupSigningEnabled(); + + const live = useMemo(() => { + // A half answer would dim entries it can't see yet. + if (loading || configLoading) return null; + const causes = causesFor(endpointStatus, endpointDetails); + if (!groupSigningEnabled) causes.sharedSign = "groupSigningOff"; + return causes; + }, [ + loading, + configLoading, + endpointStatus, + endpointDetails, + groupSigningEnabled, + ]); + + // Keyed on contents: the object is rebuilt every render. + const liveKey = live ? JSON.stringify(live) : null; + useEffect(() => { + if (liveKey) remember(JSON.parse(liveKey) as Causes); + }, [liveKey]); + + const causes = live ?? remembered; + + return useMemo(() => { + if (!causes) return null; + const reasons: QuickNavToolReasons = {}; + for (const entry of Object.keys(causes) as ToolId[]) { + const cause = causes[entry]; + if (cause === "groupSigningOff") { + // The tool's own wording, minus the full stop. + reasons[entry] = t( + "sharedSign.disabledBody", + "Collaborative signing isn't enabled on this server.", + ).replace(/\.\s*$/, ""); + continue; + } + if (!cause) continue; + // These labels normally sit in front of a tool name, hence the trailing colon. + const { key, fallback } = getDisabledLabel(cause); + reasons[entry] = t(key, fallback).replace(/:\s*$/, ""); + } + return reasons; + }, [causes, t]); +} diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx index d66d30c442..031ecd8e20 100644 --- a/frontend/editor/src/core/components/tools/RightSidebar.tsx +++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx @@ -115,7 +115,7 @@ export default function RightSidebar() { const computedWidth = () => { if (isMobile) return "100%"; - if (!isPanelVisible) return "3.5rem"; + if (!isPanelVisible) return "var(--nav-rail-w)"; return expandedWidth; }; @@ -181,7 +181,8 @@ export default function RightSidebar() { content={tool.name} position="left" arrow - delay={300} + // No delay: collapsed to icons, the tooltip is the only label. + delay={0} > ( - - - -
+ +
+ {/* Inside the Popover: Tooltip binds by cloning, and Popover passes no ref on. */} + -
-
- -
- -
-
- - + +
+
+ +
+ +
+
+
), }, { diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx new file mode 100644 index 0000000000..fc648cbfe5 --- /dev/null +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { render, act } from "@testing-library/react"; +import { + QuickNavHostProvider, + useQuickNavHost, + useRegisterQuickNavHost, + useSuppressQuickNavRail, +} from "@app/contexts/QuickNavHostContext"; + +function Probe({ onRead }: { onRead: (value: unknown) => void }) { + const host = useQuickNavHost(); + onRead({ + appMounted: host?.appMounted, + chromeless: host?.chromeless, + identity: host?.identity, + openSettings: Boolean(host?.actions.current?.openSettings), + }); + return null; +} + +function App() { + useRegisterQuickNavHost( + { identity: { displayName: "Ada", profilePictureUrl: null } }, + { openSettings: () => {} }, + ); + return null; +} + +function LoginRoute() { + useSuppressQuickNavRail(); + return null; +} + +function setup() { + let latest: Record = {}; + const view = render( + + (latest = value as Record)} /> + + , + ); + return { view, read: () => latest }; +} + +describe("QuickNavHostContext", () => { + it("keeps what the app published after it unmounts, but drops its handlers", () => { + // Data survives the gap between one app unmounting and the next registering. + const { view, read } = setup(); + + expect(read().appMounted).toBe(true); + expect(read().identity).toEqual({ + displayName: "Ada", + profilePictureUrl: null, + }); + expect(read().openSettings).toBe(true); + + view.rerender( + + {}} /> + , + ); + + // Re-read through a fresh probe in the same provider. + let after: Record = {}; + view.rerender( + + (after = value as Record)} /> + , + ); + expect(after.appMounted).toBe(true); + expect(after.openSettings).toBe(false); + }); + + it("hides the bar while a route with no app chrome is on screen", () => { + // appMounted is sticky, so it can't answer "is an app on screen now". + const { view, read } = setup(); + expect(read().chromeless).toBe(false); + + act(() => { + view.rerender( + + {}} /> + + + , + ); + }); + + let during: Record = {}; + view.rerender( + + (during = value as Record)} + /> + + + , + ); + expect(during.chromeless).toBe(true); + }); + + it("brings the bar back when that route leaves", () => { + const { view } = setup(); + + act(() => { + view.rerender( + + {}} /> + + + , + ); + }); + + let after: Record = {}; + act(() => { + view.rerender( + + (after = value as Record)} + /> + + , + ); + }); + expect(after.chromeless).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx new file mode 100644 index 0000000000..540ec8cd6c --- /dev/null +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx @@ -0,0 +1,201 @@ +import type { ToolId } from "@app/types/toolId"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +export type QuickNavToolReasons = Partial>; + +export interface QuickNavIdentity { + displayName: string; + profilePictureUrl: string | null; +} + +export interface QuickNavHostData { + /** Sticky: one app unmounts before the next one registers. */ + appMounted: boolean; + identity: QuickNavIdentity | null; + signingBadge: number; + portalAccess: boolean; + readerMode: boolean; + /** The app owns the panel; the rail's bell only reports its state. */ + notificationsOpen: boolean; + /** Translated; absent means usable. */ + toolReasons: QuickNavToolReasons; + /** Mirrors `openSettings`, which lives in a ref and so cannot trigger a render. */ + hasSettings: boolean; +} + +export interface QuickNavHostActions { + openSettings?: () => void; + /** The editor reads its tool from the URL only on mount. */ + selectTool?: (toolId: ToolId) => void; + setReaderMode?: (on: boolean) => void; + toggleNotifications?: () => void; + goToDefaultState?: () => void; + requestNavigation?: (go: () => void) => void; +} + +interface QuickNavHostValue extends QuickNavHostData { + /** Reset on unmount, unlike the data above. */ + chromeless: boolean; + setChromeless: (chromeless: boolean) => void; + /** A ref, so a click reaches the app currently mounted. */ + actions: React.RefObject; + setData: (data: Partial) => void; + setActions: (actions: QuickNavHostActions) => void; +} + +const EMPTY_REASONS: QuickNavToolReasons = {}; + +const EMPTY_DATA: QuickNavHostData = { + appMounted: false, + toolReasons: EMPTY_REASONS, + identity: null, + signingBadge: 0, + portalAccess: false, + readerMode: false, + notificationsOpen: false, + hasSettings: false, +}; + +function sameReasons( + next: QuickNavToolReasons, + prev: QuickNavToolReasons, +): boolean { + const nextKeys = Object.keys(next); + if (nextKeys.length !== Object.keys(prev).length) return false; + return nextKeys.every((key) => next[key as ToolId] === prev[key as ToolId]); +} + +const QuickNavHostContext = createContext(null); + +/** Outside both apps' providers, so each app registers what only it knows. */ +export function QuickNavHostProvider({ children }: { children: ReactNode }) { + const [data, setDataState] = useState(EMPTY_DATA); + const [chromeless, setChromelessState] = useState(false); + const actions = useRef({}); + + const setData = useCallback((next: Partial) => { + setDataState((prev) => { + const merged = { ...prev, ...next }; + const unchanged = + merged.appMounted === prev.appMounted && + merged.signingBadge === prev.signingBadge && + merged.portalAccess === prev.portalAccess && + merged.readerMode === prev.readerMode && + merged.notificationsOpen === prev.notificationsOpen && + merged.hasSettings === prev.hasSettings && + merged.identity?.displayName === prev.identity?.displayName && + merged.identity?.profilePictureUrl === + prev.identity?.profilePictureUrl && + // Compared by value: the object is rebuilt every render. + sameReasons(merged.toolReasons, prev.toolReasons); + return unchanged ? prev : merged; + }); + }, []); + + const setActions = useCallback((next: QuickNavHostActions) => { + actions.current = next; + }, []); + + const setChromeless = useCallback((next: boolean) => { + setChromelessState(next); + }, []); + + const value = useMemo( + () => ({ + ...data, + chromeless, + actions, + setData, + setActions, + setChromeless, + }), + [data, chromeless, setData, setActions, setChromeless], + ); + + return ( + + {children} + + ); +} + +export function useQuickNavHost(): QuickNavHostValue | null { + return useContext(QuickNavHostContext); +} + +/** No-ops outside the provider. */ +export function useRegisterQuickNavHost( + data: Partial, + actions: QuickNavHostActions, +): void { + const host = useQuickNavHost(); + const { + identity, + signingBadge, + portalAccess, + readerMode, + notificationsOpen, + toolReasons, + } = data; + const hasSettings = Boolean(actions.openSettings); + + useEffect(() => { + host?.setData({ + appMounted: true, + identity: identity ?? null, + signingBadge: signingBadge ?? 0, + portalAccess: portalAccess ?? false, + readerMode: readerMode ?? false, + notificationsOpen: notificationsOpen ?? false, + // Omitted when unknown, so the last answer survives a re-fetch. + ...(toolReasons ? { toolReasons } : {}), + hasSettings, + }); + // By field: identity is rebuilt every render. + }, [ + host, + identity?.displayName, + identity?.profilePictureUrl, + signingBadge, + portalAccess, + readerMode, + notificationsOpen, + toolReasons, + hasSettings, + ]); + + const setActions = host?.setActions; + + // No deps: a click has to reach the current closure. + useEffect(() => { + setActions?.(actions); + }); + + // Handlers only: clearing the data too would blink the controls mid-switch. + useEffect( + () => () => { + setActions?.({}); + }, + [setActions], + ); +} + +/** `appMounted` is sticky, so a screen that isn't the app has to say so itself. */ +export function useSuppressQuickNavRail(active = true): void { + const host = useQuickNavHost(); + const setChromeless = host?.setChromeless; + useEffect(() => { + if (!active) return; + setChromeless?.(true); + return () => setChromeless?.(false); + }, [active, setChromeless]); +} diff --git a/frontend/editor/src/core/contexts/SidebarContext.tsx b/frontend/editor/src/core/contexts/SidebarContext.tsx index ac9ddbb0df..ce0e5184bb 100644 --- a/frontend/editor/src/core/contexts/SidebarContext.tsx +++ b/frontend/editor/src/core/contexts/SidebarContext.tsx @@ -62,6 +62,11 @@ export function SidebarProvider({ children }: SidebarProviderProps) { ); } +/** For components that render outside a SidebarProvider, such as the rail's tooltips. */ +export function useOptionalSidebarContext(): SidebarContextValue | undefined { + return useContext(SidebarContext); +} + export function useSidebarContext(): SidebarContextValue { const context = useContext(SidebarContext); if (context === undefined) { diff --git a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx index 6bc61fa294..070442a966 100644 --- a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx @@ -218,8 +218,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { const setReaderMode = useCallback( (mode: boolean) => { if (mode) { + // Reading is a mode the open document is put into, not a tool run on it. actions.setWorkbench("viewer"); - actions.setSelectedTool("read"); } dispatch({ type: "SET_READER_MODE", payload: mode }); }, diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index 0d7a571f98..efb6af40d2 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -1,4 +1,11 @@ -import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; +import { + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { Group } from "@mantine/core"; @@ -14,6 +21,7 @@ import { useFileContext } from "@app/contexts/file/fileHooks"; import { useNavigationState, useNavigationActions, + useNavigationGuard, } from "@app/contexts/NavigationContext"; import { isApplyingRestoredView } from "@app/services/workbenchSession"; import { useViewer } from "@app/contexts/ViewerContext"; @@ -28,10 +36,21 @@ import FileSidebar from "@app/components/shared/FileSidebar"; import FileManager from "@app/components/FileManager"; import LocalIcon from "@app/components/shared/LocalIcon"; import AppConfigModal from "@app/components/shared/AppConfigModalLazy"; -import { getStartupNavigationAction } from "@app/utils/homePageNavigation"; +import { + getStartupNavigationAction, + getDefaultWorkbenchForFileCount, +} from "@app/utils/homePageNavigation"; import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import { stripBasePath } from "@app/constants/app"; import { HomePageExtensions } from "@app/components/home/HomePageExtensions"; +import { QuickNavHostBridge } from "@app/components/shared/quickNav/QuickNavHostBridge"; +import type { QuickNavToolReasons } from "@app/contexts/QuickNavHostContext"; +import { + getToolDisabledReason, + getDisabledLabel, +} from "@app/components/tools/fullscreen/shared"; +import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; +import { consumeReaderModeRequest } from "@app/utils/pendingReaderMode"; import { FilesPageProvider, useFilesPage, @@ -42,6 +61,7 @@ import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel"; import type { FileSidebarProps } from "@app/components/shared/FileSidebar"; import { Button } from "@app/ui/Button"; +import "@app/components/layout/WorkspaceFrame.css"; import "@app/pages/HomePage.css"; const SIDEBAR_COLLAPSED_STORAGE_KEY = "stirling.fileSidebarCollapsed"; @@ -90,9 +110,11 @@ export default function HomePage() { handleToolSelect, handleBackToTools, readerMode, + setReaderMode, setLeftPanelView, toolAvailability, customWorkbenchViews, + toolRegistry, } = useToolWorkflow(); const navigate = useNavigate(); @@ -103,6 +125,7 @@ export default function HomePage() { const [activeMobileView, setActiveMobileView] = useState("tools"); const isProgrammaticScroll = useRef(false); const [configModalOpen, setConfigModalOpen] = useState(false); + const otherApp = useOtherAppSwitch(); const location = useLocation(); // Persisted user preference for the FileSidebar collapsed state. Auto- // collapse on /files is layered on top in the transition effect below and @@ -152,8 +175,64 @@ export default function HomePage() { const { activeFiles } = useFileContext(); const navigationState = useNavigationState(); + const { requestNavigation } = useNavigationGuard(); + + // From the processor's Reader entry. Ref-guarded: one-shot, and StrictMode double-invokes. + const consumedReaderRequest = useRef(false); + useEffect(() => { + if (consumedReaderRequest.current) return; + consumedReaderRequest.current = true; + if (consumeReaderModeRequest()) setReaderMode(true); + }, [setReaderMode]); const { actions } = useNavigationActions(); + const { searchInterfaceActions } = useViewer(); + + // Reading hides both search controls, so leave it first. e.code, for non-QWERTY layouts. + const focusSearchAfterRestore = useRef(false); + useEffect(() => { + if (!readerMode) return; + const onKeyDown = (e: KeyboardEvent) => { + const combo = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey; + if (!combo) return; + if (e.code !== "KeyK" && e.code !== "KeyF") return; + // Same carve-out the search itself makes: a dialog owns the keyboard. + if ((e.target as HTMLElement | null)?.closest?.('[role="dialog"]')) + return; + e.preventDefault(); + setReaderMode(false); + if (e.code === "KeyK") { + focusSearchAfterRestore.current = true; + return; + } + // Visibility is state, so it can open before the bar it renders in exists. + searchInterfaceActions.open(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [readerMode, setReaderMode, searchInterfaceActions]); + + useEffect(() => { + if (readerMode || !focusSearchAfterRestore.current) return; + focusSearchAfterRestore.current = false; + requestAnimationFrame(() => + window.dispatchEvent(new Event("superSearch:focus")), + ); + }, [readerMode]); + + // Clean slate: no tool, out of the file library and reading. + const goToDefaultState = useCallback(() => { + handleBackToTools(); + if (location.pathname.startsWith("/files")) navigate(EDITOR_BASENAME); + actions.setWorkbench(getDefaultWorkbenchForFileCount(activeFiles.length)); + }, [ + handleBackToTools, + location.pathname, + navigate, + actions, + activeFiles.length, + ]); + // Sync the /files* URL into the workbench state so the file manager view // takes over the workbench area when the user lands on it. This is the // only state-of-truth for the active workbench, so keep the URL pinned. @@ -194,6 +273,17 @@ export default function HomePage() { prevWorkbenchRef.current = curr; // fileSidebarCollapsed read as snapshot on transition only. }, [navigationState.workbench]); + // Imperative, so the toggle still works while reading. Never persisted: not a preference. + const prevReaderModeRef = useRef(readerMode); + useEffect(() => { + if (readerMode !== prevReaderModeRef.current) { + setFileSidebarCollapsed( + readerMode ? true : readPersistedSidebarCollapsed(), + ); + prevReaderModeRef.current = readerMode; + } + }, [readerMode]); + const { setActiveFileIndex } = useViewer(); const prevFileCountRef = useRef(activeFiles.length); @@ -242,6 +332,38 @@ export default function HomePage() { const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo"); + // The tool picker's own helpers, so the wording can't drift. + const quickNavToolReasons = useMemo(() => { + const reasons: QuickNavToolReasons = {}; + for (const id of ["automate", "sharedSign"] as const) { + const tool = toolRegistry[id]; + if (!tool) continue; + const disabledReason = getToolDisabledReason( + id, + tool, + toolAvailability, + config?.premiumEnabled, + ); + if (!disabledReason) continue; + const { key, fallback } = getDisabledLabel(disabledReason); + reasons[id] = t(key, fallback).replace(/:\s*$/, ""); + } + return reasons; + }, [toolRegistry, toolAvailability, config?.premiumEnabled, t]); + + // Shared with the sidebar's own toggle. On /files it leaves rather than collapses. + const handleSidebarToggle = useCallback(() => { + if (navigationState.workbench === "myFiles") { + navigate(EDITOR_BASENAME); + return; + } + setFileSidebarCollapsed((c) => { + const next = !c; + writePersistedSidebarCollapsed(next); + return next; + }); + }, [navigationState.workbench, navigate]); + const [showSwipeHint, setShowSwipeHint] = useState( () => !readSwipeHintSeen(), ); @@ -395,6 +517,16 @@ export default function HomePage() { return (
+ setConfigModalOpen(true)} + requestNavigation={requestNavigation} + readerMode={readerMode} + onSetReaderMode={setReaderMode} + onGoToDefaultState={goToDefaultState} + onSelectTool={handleToolSelect} + toolReasons={quickNavToolReasons} + /> {isMobile ? (
- - ) : undefined - } - onToggleCollapse={() => { - if (navigationState.workbench === "myFiles") { - navigate(EDITOR_BASENAME); - return; +
+ { - const next = !c; - writePersistedSidebarCollapsed(next); - return next; - }); - }} - onOpenSettings={() => setConfigModalOpen(true)} - /> + toggleIcon={ + navigationState.workbench === "myFiles" ? ( + + ) : undefined + } + active={navigationState.workbench === "myFiles"} + // Forced: a deep link to /files has no transition to collapse on. + collapsed={ + navigationState.workbench === "myFiles" || + fileSidebarCollapsed + } + onToggleCollapse={handleSidebarToggle} + onOpenSettings={() => setConfigModalOpen(true)} + /> +
{!hideToolPanel && } diff --git a/frontend/editor/src/core/routes/hasPortal.ts b/frontend/editor/src/core/routes/hasPortal.ts new file mode 100644 index 0000000000..3d7f107ac3 --- /dev/null +++ b/frontend/editor/src/core/routes/hasPortal.ts @@ -0,0 +1,2 @@ +/** Whether this build ships the processor. Shadowed per build. */ +export const HAS_PORTAL = false; diff --git a/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts b/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts index df3ce94b23..a2e2c9c7e3 100644 --- a/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts +++ b/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts @@ -60,7 +60,8 @@ function fixture(filename: string): string { } async function openSamplePdfInViewer(page: import("@playwright/test").Page) { - await page.goto("/read"); + // Not /read: reading collapses the workbench bar these sidebars are toggled from. + await page.goto("/"); await page.waitForLoadState("domcontentloaded"); await page .locator('[data-testid="file-input"]') diff --git a/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts b/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts index 67df3865e1..f0da928122 100644 --- a/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts @@ -23,7 +23,8 @@ const SAMPLE_PDF = path.join( ); async function openViewerWithSample(page: import("@playwright/test").Page) { - await page.goto("/read"); + // Not /read: reading collapses the workbench bar these sidebars are toggled from. + await page.goto("/"); await page.waitForLoadState("domcontentloaded"); await page .locator('[data-testid="file-input"]') diff --git a/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts b/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts index 48765392fe..c1a71ac48c 100644 --- a/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts @@ -27,6 +27,7 @@ async function restoreEnabled( } const NO_RESTORE = "this build ships the workbench restore off"; +const NO_PORTAL = "this build ships no processor to switch to"; // Switching editor -> processor unmounts every editor provider; the session record // in sessionStorage is what brings the workbench back on return. @@ -73,8 +74,13 @@ test.describe("Workbench survives the editor/processor switch", () => { page.getByRole("radio", { name: /Active Files/i }), ).toBeChecked(); - // Out through the sidebar footer switch - the real user path. - await page.getByRole("button", { name: "Open PDF Processor" }).click(); + // Out through the rail's processor mark, the only chrome that offers the switch. + const processorMark = page.getByRole("button", { name: /^Processor$/i }); + test.skip( + !(await processorMark.isVisible({ timeout: 5_000 }).catch(() => false)), + NO_PORTAL, + ); + await processorMark.click(); await expect(page).toHaveURL(/\/processor/, { timeout: 15000 }); // Split the two halves of the feature: if this fails the writer is at fault, diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css index 31f9666271..1a74158022 100644 --- a/frontend/editor/src/core/theme/colors.css +++ b/frontend/editor/src/core/theme/colors.css @@ -46,7 +46,7 @@ html[data-app-theme="light"] { non-text floor applies. Scheme-independent: a filled badge reads white on either ground. */ --c-success-solid: var(--p-green-700); - --c-danger-solid: var(--p-red-600); + --c-danger-solid: var(--p-red-700); --c-warning-solid: var(--p-amber-700); --c-neutral-solid: var(--p-gray-600); --c-accent-solid: var(--p-blue-600); diff --git a/frontend/editor/src/core/theme/dimensions.css b/frontend/editor/src/core/theme/dimensions.css index 554baddcca..50531641cb 100644 --- a/frontend/editor/src/core/theme/dimensions.css +++ b/frontend/editor/src/core/theme/dimensions.css @@ -30,7 +30,12 @@ --radius-nav: 0.625rem; --nav-gutter: 0.5rem; - --nav-rail-w: 3.5rem; + /* Every minimised rail is this wide, so they line up as one column of icons. */ + --nav-rail-w: 3rem; + /* Header row, so the rail's brand and a sidebar's wordmark line up. */ + --nav-header-h: 3rem; + --sidebar-w: 16.25rem; + --sidebar-collapsed-w: var(--nav-rail-w); /* ── Layout sizing ── */ --footer-height: 2rem; diff --git a/frontend/editor/src/core/ui/NavSurface.tsx b/frontend/editor/src/core/ui/NavSurface.tsx index 38947fb91f..37caaa063e 100644 --- a/frontend/editor/src/core/ui/NavSurface.tsx +++ b/frontend/editor/src/core/ui/NavSurface.tsx @@ -2,8 +2,8 @@ import { forwardRef, type HTMLAttributes } from "react"; import "@app/ui/NavSurface.css"; export interface NavSurfaceProps extends HTMLAttributes { - /** Element to render; `section`/`aside` when the box is a landmark. */ - as?: "div" | "section" | "aside"; + /** Element to render; `section`/`aside`/`nav` when the box is a landmark. */ + as?: "div" | "section" | "aside" | "nav"; } /** diff --git a/frontend/editor/src/core/utils/homePageNavigation.ts b/frontend/editor/src/core/utils/homePageNavigation.ts index 001e026710..7a91bfec65 100644 --- a/frontend/editor/src/core/utils/homePageNavigation.ts +++ b/frontend/editor/src/core/utils/homePageNavigation.ts @@ -1,4 +1,4 @@ -import type { WorkbenchType } from "@app/types/workbench"; +import { getDefaultWorkbench, type WorkbenchType } from "@app/types/workbench"; export type StartupWorkbench = "viewer" | "fileEditor"; @@ -7,6 +7,13 @@ export interface StartupNavigationAction { activeFileIndex?: number; } +/** Several files means the file editor; one or none the viewer. */ +export function getDefaultWorkbenchForFileCount( + fileCount: number, +): WorkbenchType { + return fileCount > 1 ? "fileEditor" : getDefaultWorkbench(); +} + export function getStartupNavigationAction( previousFileCount: number, currentFileCount: number, diff --git a/frontend/editor/src/core/utils/pendingReaderMode.ts b/frontend/editor/src/core/utils/pendingReaderMode.ts new file mode 100644 index 0000000000..faacfba6b8 --- /dev/null +++ b/frontend/editor/src/core/utils/pendingReaderMode.ts @@ -0,0 +1,13 @@ +let pending = false; + +/** Carries "open in reading mode" across an app switch, and deliberately not a reload. */ +export function requestReaderMode(): void { + pending = true; +} + +/** True once per request. */ +export function consumeReaderModeRequest(): boolean { + if (!pending) return false; + pending = false; + return true; +} diff --git a/frontend/editor/src/core/utils/viewTransition.test.ts b/frontend/editor/src/core/utils/viewTransition.test.ts new file mode 100644 index 0000000000..5fb11c8aa2 --- /dev/null +++ b/frontend/editor/src/core/utils/viewTransition.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { withViewTransition } from "@app/utils/viewTransition"; + +// The stub carries only the field the helper reads, hence the cast through unknown. +type MutableDoc = { startViewTransition?: unknown }; +const doc = document as unknown as MutableDoc; + +function stubApi(): ReturnType { + const start = vi.fn((cb: () => void) => { + cb(); + return { finished: Promise.resolve() }; + }); + doc.startViewTransition = start; + return start; +} + +function stubReducedMotion(reduced: boolean): void { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: reduced && query.includes("prefers-reduced-motion"), + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })); +} + +afterEach(() => { + delete doc.startViewTransition; + vi.unstubAllGlobals(); +}); + +describe("withViewTransition", () => { + it("runs the update inside a transition when one is possible", async () => { + const start = stubApi(); + stubReducedMotion(false); + const update = vi.fn(); + + await withViewTransition(update); + + expect(start).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + }); + + it("skips the transition when the user asked for less motion", async () => { + // The state change must still happen - only the animation is dropped. + const start = stubApi(); + stubReducedMotion(true); + const update = vi.fn(); + + await withViewTransition(update); + + expect(start).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledTimes(1); + }); + + it("still applies the update where the API is unavailable", async () => { + stubReducedMotion(false); + const update = vi.fn(); + + await withViewTransition(update); + + expect(update).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/editor/src/core/utils/viewTransition.ts b/frontend/editor/src/core/utils/viewTransition.ts index 049c3e7673..85a05ca78d 100644 --- a/frontend/editor/src/core/utils/viewTransition.ts +++ b/frontend/editor/src/core/utils/viewTransition.ts @@ -4,21 +4,20 @@ type ViewTransitionDoc = Document & { startViewTransition?: (cb: () => void) => { finished: Promise }; }; -/** - * Run a state update inside a View Transition so the browser cross-fades - * (and morphs any elements sharing a {@code view-transition-name}) between - * the before/after DOMs. - * - * Falls back to a plain synchronous update when the API is unavailable - * (Firefox <130, JSDOM, motion-reduced preference). - */ +/** Runs a state update in a View Transition, plainly where that is unavailable. */ export function withViewTransition(update: () => void): Promise { if (typeof document === "undefined") { update(); return Promise.resolve(); } + // Callers don't each check: reduced motion still gets the state change. + const reduced = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const doc = document as ViewTransitionDoc; - if (doc.startViewTransition) { + if (doc.startViewTransition && !reduced) { return doc.startViewTransition(() => flushSync(update)).finished; } update(); diff --git a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx deleted file mode 100644 index 21896d9a1d..0000000000 --- a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Logo } from "@app/ui/Logo"; -import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; - -/** - * Desktop inherits proprietary's layers but does not ship the portal (see - * desktop/routes/adminRouteExtensions), so there's nothing to switch to — - * shadow the brand header back to a plain logo. (Also avoids the desktop - * bundle referencing @portal via the proprietary switcher's imports.) - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - return ( - - ); -} diff --git a/frontend/editor/src/desktop/routes/hasPortal.ts b/frontend/editor/src/desktop/routes/hasPortal.ts new file mode 100644 index 0000000000..0eec40f4c9 --- /dev/null +++ b/frontend/editor/src/desktop/routes/hasPortal.ts @@ -0,0 +1,2 @@ +/** Desktop inherits proprietary's app but never ships the portal. */ +export const HAS_PORTAL = false; diff --git a/frontend/editor/src/portal/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx index fe8f775fd0..ec6c720297 100644 --- a/frontend/editor/src/portal/components/AppShell.tsx +++ b/frontend/editor/src/portal/components/AppShell.tsx @@ -7,8 +7,11 @@ import { PortalSearchBar } from "@portal/components/PortalSearchBar"; import { useUI } from "@portal/contexts/UIContext"; import { MenuIcon, SearchIcon } from "@portal/components/icons"; import { Logo } from "@app/ui/Logo"; +import "@app/components/layout/WorkspaceFrame.css"; +import { QuickNavHostBridge } from "@app/components/shared/quickNav/QuickNavHostBridge"; import "@portal/components/AppShell.css"; import { NotificationBell } from "@app/components/notifications/NotificationBell"; +import { useIsPhone } from "@app/hooks/useIsMobile"; /** * Compact header shown only under the mobile breakpoint (CSS-hidden on @@ -58,8 +61,10 @@ function MobileTopbar() { * prop-free. */ export function AppShell({ children }: { children: ReactNode }) { - const { mobileNavOpen, closeMobileNav } = useUI(); + const { mobileNavOpen, closeMobileNav, openSettings } = useUI(); const { pathname } = useLocation(); + // Below this width the rail, and the bell it carries, is gone. + const isPhone = useIsPhone(); // Navigating (tap on a nav row, back button, deep link) always dismisses the // drawer. Depends on pathname only: the close fn's identity changes with any @@ -79,7 +84,11 @@ export function AppShell({ children }: { children: ReactNode }) { return (
- + {/* portalAccess: being here is proof the processor is available. */} + openSettings()} /> +
+ +
{mobileNavOpen && (
-
- -
+ {/* Phone only: above that the rail carries it, and this would be a second. */} + {isPhone && ( +
+ +
+ )}
{children}
diff --git a/frontend/editor/src/portal/components/EditorStatusCard.tsx b/frontend/editor/src/portal/components/EditorStatusCard.tsx index e4918ed859..774dc4d1be 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.tsx +++ b/frontend/editor/src/portal/components/EditorStatusCard.tsx @@ -11,31 +11,9 @@ import { import { type EditorInstance } from "@portal/api/editorDeploy"; import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; import "@portal/theme/surface.css"; +import { BrandTile } from "@app/components/shared/BrandTile"; import "@portal/components/EditorStatusCard.css"; -/** The Stirling brand mark, drawn at the hero size. Decorative. */ -function StirlingMark() { - return ( - - - - - - ); -} - /** The instance to headline: the busiest healthy one, else the first. */ function primaryInstance(instances: EditorInstance[]): EditorInstance | null { if (instances.length === 0) return null; @@ -126,7 +104,7 @@ export function EditorStatusCard({ footer }: EditorStatusCardProps) { >
- +
diff --git a/frontend/editor/src/portal/components/PortalSearchBar.css b/frontend/editor/src/portal/components/PortalSearchBar.css index 8e253b6136..847991af20 100644 --- a/frontend/editor/src/portal/components/PortalSearchBar.css +++ b/frontend/editor/src/portal/components/PortalSearchBar.css @@ -1,5 +1,4 @@ -/* Unpainted strip at the top of the main column. Height matches the sidebar's - logo row (.portal-sidebar__logo, 51px) so the search lines up with the brand. */ +/* Unpainted strip at the top of the main column, matching the sidebar header's height. */ .portal-searchbar { display: flex; align-items: center; diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index 48b54b177d..43f8c44695 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -1,8 +1,10 @@ .portal-sidebar { - width: 15rem; + width: var(--sidebar-w); height: 100vh; height: 100dvh; /* track mobile browser chrome */ - background: var(--c-bg); + /* Matches the editor's sidebar: one solid panel with a rule on the content side. */ + background: var(--c-surface); + border-inline-end: 1px solid var(--c-border-subtle); display: flex; flex-direction: column; flex-shrink: 0; @@ -21,10 +23,6 @@ /* Nav labels stay on one line and are clipped by the narrowing rail so they reveal/hide cleanly as the width animates rather than wrapping. */ -.portal-sidebar__nav, -.portal-sidebar__footer { - overflow-x: hidden; -} .portal-sidebar .sui-navitem__label, .portal-sidebar__section-label { white-space: nowrap; @@ -34,6 +32,8 @@ .portal-sidebar__close { display: none; flex-shrink: 0; + /* Trailing edge: on mobile this is the row's only control. */ + margin-left: auto; } .portal-sidebar__collapse { @@ -78,23 +78,16 @@ /* ---- Collapsed icon rail (desktop only) ---- */ .portal-sidebar[data-collapsed] { - width: var(--nav-rail-w); -} -.portal-sidebar[data-collapsed] .portal-sidebar__logo { - flex-direction: column; - height: auto; - padding: 0.5rem 0; - gap: 0.375rem; -} -.portal-sidebar[data-collapsed] .portal-sidebar__collapse { - margin-left: 0; + width: var(--sidebar-collapsed-w); } +/* Flush, like the nav: the selected row runs the full width of the rail. */ .portal-sidebar[data-collapsed] .portal-sidebar__nav { - padding-inline: 0.375rem; + padding-inline: 0; } +/* Stretch, not centre: a centred group shrinks to its content, so rows can't fill the rail. */ .portal-sidebar[data-collapsed] .portal-sidebar__section { padding-inline: 0; - align-items: center; + align-items: stretch; } .portal-sidebar[data-collapsed] .portal-sidebar__section-label { display: none; @@ -111,23 +104,16 @@ margin-inline: 0; padding-inline: 0; width: 100%; -} -/* Neutralise the active-item edge-bar geometry (negative margins + overhang) - that assumes the full-width rail. */ -.portal-sidebar[data-collapsed] .sui-navitem.is-active { - width: 100%; - margin-inline: 0; - border-left: none; - border-radius: 0.5rem; - padding-left: 0; + /* A square target, so it takes the rail's radius rather than NavItem's pill. */ + border-radius: var(--radius-md); } .portal-sidebar[data-collapsed] .portal-sidebar__footer { margin-inline: 0.375rem; } -.portal-sidebar__logo { - height: 3.1875rem; /* 51px */ - padding: 0 0.875rem; +.portal-sidebar__header { + height: var(--nav-header-h); + padding: 0 var(--nav-gutter); display: flex; align-items: center; gap: 0.5rem; @@ -136,35 +122,58 @@ .portal-sidebar__nav { flex: 0 1 auto; overflow-y: auto; - padding: 0.75rem 0.625rem; + overflow-x: clip; + /* No inline inset above the rows, so a row is full width and needs no bleed past the clip. */ + padding: var(--nav-gutter) 0; display: flex; flex-direction: column; - gap: 0.5rem; + gap: var(--nav-gutter); } .portal-sidebar .sui-navitem { - margin-inline: 0.25rem; - padding-inline: 0.625rem; + margin-inline: 0; + padding-inline: 1.75rem; } -.portal-sidebar .sui-navitem.is-active { - width: calc(100% + 0.75rem); - margin-inline: -0.375rem; +/* The selected view, marked as the rail marks the current app: a knocked-out solid block. */ +.portal-sidebar .sui-navitem.is-active, +.portal-sidebar .sui-navitem.is-active:hover { + background: var(--c-text); + color: var(--c-surface); border-radius: 0; - border-left: 3px solid var(--c-primary); - padding-left: calc(1.25rem - 3px); +} + +/* On a dark ground full ink is white, so mix the block back toward the surface. */ +[data-theme="dark"] .portal-sidebar .sui-navitem.is-active, +[data-theme="dark"] .portal-sidebar .sui-navitem.is-active:hover, +html[data-app-theme="midnight"] .portal-sidebar .sui-navitem.is-active, +html[data-app-theme="midnight"] .portal-sidebar .sui-navitem.is-active:hover, +[data-mantine-color-scheme="dark"] .portal-sidebar .sui-navitem.is-active, +[data-mantine-color-scheme="dark"] + .portal-sidebar + .sui-navitem.is-active:hover { + background: color-mix(in srgb, var(--c-text) 80%, var(--c-surface)); + color: var(--c-surface); } .portal-sidebar__section { - padding: 0.5rem 0.375rem 0.375rem; + padding: 0.5rem 0 0.375rem; display: flex; flex-direction: column; gap: 0.375rem; } +/* Flattened here; two classes deep to beat .sui-nav-surface regardless of load order. */ +.portal-sidebar .sui-nav-surface { + background: transparent; + border: 0; + border-radius: 0; +} + .portal-sidebar__section-label { margin: 0; - padding: 0 0.5rem; + /* Its own 0.5rem, plus the inset the nav and section no longer add. */ + padding: 0 1.375rem; font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.02em; @@ -181,4 +190,18 @@ only positions it. */ .portal-sidebar__footer { margin: 0 0.625rem 0.75rem; + overflow-x: hidden; +} + +/* Fills the frame, not the viewport: a 100vh sticky column would overhang it. */ +.workspace-frame .portal-sidebar { + height: 100%; + position: static; +} + +@media (max-width: 48rem) { + .workspace-frame .portal-sidebar { + position: fixed; + height: auto; + } } diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 685fa32722..2895ad4ec4 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -1,20 +1,16 @@ import { useMediaQuery } from "@mantine/hooks"; import { Tooltip } from "@mantine/core"; import { ActionIcon, NavItem, NavSurface } from "@app/ui"; -import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; +import { SidebarToggleButton } from "@app/components/shared/SidebarToggleButton"; +import { Logo } from "@app/ui/Logo"; import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary"; import { useOpenPlan } from "@portal/hooks/useOpenPlan"; -import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; -import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; -import { EDITOR_BASENAME } from "@app/routes/editorBasename"; -import { takeEditorReturnPath } from "@app/services/workbenchSession"; import { CloseIcon } from "@portal/components/icons"; import { GROUP_PROCESSOR, @@ -30,19 +26,17 @@ const NAV_SECTIONS: NavGroup[] = [ ]; /** Must match the shell breakpoint in AppShell.css / Sidebar.css. */ -const MOBILE_QUERY = "(max-width: 48rem)"; +export const MOBILE_QUERY = "(max-width: 48rem)"; export function Sidebar() { const { activeView, setActiveView } = useView(); const { - openSettings, mobileNavOpen, closeMobileNav, sidebarCollapsed, toggleSidebarCollapsed, } = useUI(); const { t } = useTranslation(); - const navigate = useNavigate(); const isMobile = useMediaQuery(MOBILE_QUERY, false, { getInitialValueInEffect: false, }); @@ -54,14 +48,6 @@ export function Sidebar() { // off-canvas drawer, so the icon-rail state never applies there. const collapsed = sidebarCollapsed && !isMobile; - // Editor and portal are one SPA when the editor serves this origin's root, so - // the switch stays client-side; an absolute EDITOR_URL (dev cross-app setup) - // needs a full page load. - const goToEditor = () => { - if (EDITOR_IS_SAME_APP) navigate(takeEditorReturnPath() ?? EDITOR_BASENAME); - else window.location.href = EDITOR_URL; - }; - // Procurement is no longer a nav tab — it lives on Home as the deal-status hero and expands into // a takeover modal (matching the marketing prototype). @@ -107,25 +93,13 @@ export function Sidebar() { // Off-canvas on mobile: remove from the tab order and accessibility tree. inert={isMobile && !mobileNavOpen} > -
- +
+ {!collapsed && } - - - + } collapsed={collapsed} /> diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx index c64935bb16..ca040d4bbe 100644 --- a/frontend/editor/src/proprietary/App.tsx +++ b/frontend/editor/src/proprietary/App.tsx @@ -18,6 +18,8 @@ const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage")); import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; +import { AppFrame } from "@app/components/layout/AppFrame"; +import { NoAppChrome } from "@app/components/layout/NoAppChrome"; import { RootGate } from "@app/routes/RootGate"; // Import global styles @@ -80,40 +82,52 @@ export default function App() { } /> - {/* Admin-only route-set (the portal): its own top-level shell, mounted - before the catch-all. Absent from core/desktop builds (empty stub). */} - {getAdminRouteExtensions()} + {/* Both apps, under a shared frame so the rail renders once outside them. */} + }> + {/* The portal: its own shell, before the catch-all. An empty stub in core. */} + {getAdminRouteExtensions()} - {/* All other routes need AppProviders for backend integration. - RootGate makes "/" route by role BEFORE any of it mounts, so a user - bound for the processor never boots the editor on the way. */} - - - - - } /> - {/* Self-hosted has no signup - accounts are created by an - admin. Old links land on login instead. */} - } - /> - } /> - } /> - } /> - {/* The editor and its tool routes - Landing handles auth logic */} - } /> - - - {WATCHED_FOLDERS_ENABLED && } - - - - } - /> + {/* All other routes need AppProviders for backend integration. RootGate + routes "/" by role before any of it mounts. */} + + + + + {/* Not the app: no rail over any of these, ever. */} + }> + } /> + {/* Self-hosted has no signup: old links land on login. */} + } + /> + } + /> + } + /> + } + /> + + {/* The editor and its tool routes - Landing handles auth logic */} + } /> + + + {WATCHED_FOLDERS_ENABLED && } + + + + } + /> + ); diff --git a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx deleted file mode 100644 index 9ba0b6438d..0000000000 --- a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Logo } from "@app/ui/Logo"; -import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; -import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; -import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; - -/** - * Sidebar brand header for builds that ship the processor. When this user can - * open it, the Stirling logo doubles as the editor⇄processor switcher: the mark - * morphs into a chevron and opens the switch menu (the same BrandSwitcher the - * processor sidebar uses). Users without access get a plain logo. - * - * The access gate lives in {@link useOtherAppSwitch} so this header and the - * sidebar footer's "Open PDF Processor" row are driven by one answer. - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - const otherApp = useOtherAppSwitch(); - - if (!otherApp) { - return ( - - ); - } - - return ( - - ); -} diff --git a/frontend/editor/src/proprietary/data/processorEntitySearch.ts b/frontend/editor/src/proprietary/data/processorEntitySearch.ts index 544babf59f..58b32b64d3 100644 --- a/frontend/editor/src/proprietary/data/processorEntitySearch.ts +++ b/frontend/editor/src/proprietary/data/processorEntitySearch.ts @@ -6,15 +6,10 @@ import type { PortalEntityItems, PortalEntityScopeId, } from "@portal/search/entitySearch"; +import { HAS_PORTAL } from "@app/routes/hasPortal"; type EntitySearchModule = typeof import("@portal/search/entitySearch"); -// Mirrors the admin-route seam's gate: the portal route-set is only mounted in -// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not -// fetch or offer entities that have nowhere to open. -const includePortal = - import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; - const NO_GROUPS: SuperSearchGroup[] = []; const NO_SCOPES: readonly PortalEntityScopeId[] = []; @@ -41,7 +36,8 @@ export function useProcessorEntityGroups( ): SuperSearchGroup[] { const [mod, setMod] = useState(null); const modRef = useRef(null); - const active = enabled && includePortal; + // Without the portal these entities have nowhere to open, so don't fetch them. + const active = enabled && HAS_PORTAL; const hasQuery = trimmed.length > 0; useEffect(() => { diff --git a/frontend/editor/src/proprietary/data/processorSearchIndex.ts b/frontend/editor/src/proprietary/data/processorSearchIndex.ts index ae48893e25..8d99b2f77a 100644 --- a/frontend/editor/src/proprietary/data/processorSearchIndex.ts +++ b/frontend/editor/src/proprietary/data/processorSearchIndex.ts @@ -3,15 +3,10 @@ import { PORTAL_BASENAME } from "@app/routes/portalBasename"; // the lazy portal chunk into the main bundle the way @portal/* values would. import { usersCapabilities } from "@app/portal/usersCapabilities"; import type { ProcessorSearchEntry } from "@core/data/processorSearchIndex"; +import { HAS_PORTAL } from "@app/routes/hasPortal"; export type { ProcessorSearchEntry }; -// Mirrors the admin-route seam's gate: the portal route-set is only mounted in -// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not -// offer destinations that would 404 elsewhere. -const includePortal = - import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; - /** * The portal's in-app views. Deliberately a static mirror of the portal's nav * (labels via the same portal.nav.* keys its sidebar uses) rather than an @@ -91,7 +86,8 @@ const VIEWS: ProcessorSearchEntry[] = [ }, ]; -export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = includePortal +// Empty without the portal: these destinations would 404. +export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = HAS_PORTAL ? VIEWS : []; diff --git a/frontend/editor/src/proprietary/routes/Landing.tsx b/frontend/editor/src/proprietary/routes/Landing.tsx index 62c9083aad..7406078d30 100644 --- a/frontend/editor/src/proprietary/routes/Landing.tsx +++ b/frontend/editor/src/proprietary/routes/Landing.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { Navigate, useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "@app/auth/UseSession"; import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useSuppressQuickNavRail } from "@app/contexts/QuickNavHostContext"; import HomePage from "@app/pages/HomePage"; import { useBackendProbe } from "@app/hooks/useBackendProbe"; import { EDITOR_BASENAME } from "@app/routes/editorBasename"; @@ -27,6 +28,9 @@ export default function Landing() { const loading = authLoading || configLoading || backendProbe.loading; + // The backend-down screen is not the app. Loading is: it resolves in a moment. + useSuppressQuickNavRail(!session && backendProbe.status !== "up"); + // Debug: Track Landing component lifecycle useEffect(() => { const mountId = Math.random().toString(36).substring(7); diff --git a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx index 22ce46f03c..a422376086 100644 --- a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx +++ b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx @@ -2,11 +2,9 @@ import { lazy } from "react"; import type { ReactElement } from "react"; import { Route } from "react-router-dom"; import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { HAS_PORTAL } from "@app/routes/hasPortal"; -const includePortal = - import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; - -const PortalApp = includePortal +const PortalApp = HAS_PORTAL ? lazy(async () => { const m = await import("@portal/PortalApp"); return { default: m.PortalApp }; @@ -16,7 +14,7 @@ const PortalApp = includePortal /** * Return leg of the account-link handshake, which Stirling redirects to with the admin's session in the URL fragment. */ -const ConnectCallback = includePortal +const ConnectCallback = HAS_PORTAL ? lazy(async () => { const m = await import("@portal/views/ConnectCallback"); return { default: m.default }; diff --git a/frontend/editor/src/proprietary/routes/hasPortal.ts b/frontend/editor/src/proprietary/routes/hasPortal.ts new file mode 100644 index 0000000000..e3c54bc50e --- /dev/null +++ b/frontend/editor/src/proprietary/routes/hasPortal.ts @@ -0,0 +1,3 @@ +/** Dev always ships it, so the switch is there to work on. */ +export const HAS_PORTAL = + import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index 2e091ebf05..07a563ea5f 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -19,6 +19,8 @@ import OAuthConsent from "@app/routes/OAuthConsent"; import ConnectApprove from "@app/routes/ConnectApprove"; import ShareLinkPage from "@app/routes/ShareLinkPage"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; +import { AppFrame } from "@app/components/layout/AppFrame"; +import { NoAppChrome } from "@app/components/layout/NoAppChrome"; import OnboardingBootstrap from "@app/components/OnboardingBootstrap"; import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap"; import UsageLimitModalHost from "@app/components/UsageLimitModalHost"; @@ -96,46 +98,63 @@ export default function App() { } /> - {/* Admin-only route-set (the portal): its own top-level shell, mounted - before the catch-all. */} - {getAdminRouteExtensions()} + {/* Both apps, under a shared frame so the rail renders once outside them. */} + }> + {/* The portal: its own top-level shell, before the catch-all. */} + {getAdminRouteExtensions()} - {/* Everything else needs the auth/backend providers. RootGate makes "/" - route by role BEFORE any of it mounts, so a user bound for the - processor never boots the editor on the way. */} - - - - - - - } /> - } /> - } /> - } /> - } /> - {/* Human half of the self-hosted account-link handshake. It - lives on this origin because a customer hostname can - never be in the provider's redirect allow-list. */} - } /> - {/* Shared-file links. Team invites are NOT routed here: on - SaaS they are accepted in-app via the Supabase team - invitation banner, not the Spring password-based - /invite/:token page used by the self-hosted build. */} - } /> - } /> - - - - - - } - /> + {/* Everything else needs the auth/backend providers. RootGate routes "/" + by role before any of it mounts. */} + + + + + + + {/* Not the app: no rail over any of these, ever. */} + }> + } /> + } /> + } + /> + } /> + } + /> + {/* Human half of the self-hosted account-link handshake. + It lives on this origin because a customer hostname can + never be in the provider's redirect allow-list. Grouped + with the pages above: it is an approval step, not the + app. */} + } /> + {/* Shared-file links. Team invites are NOT routed here: + on SaaS they are accepted in-app via the Supabase team + invitation banner, not the Spring password-based + /invite/:token page used by the self-hosted build. */} + } + /> + + } /> + + + + + + } + /> + );