Quick access bar and old school sidebars (#7695)

This commit is contained in:
Reece Browne
2026-08-27 23:15:39 +01:00
committed by GitHub
parent be13028209
commit f71b0247da
76 changed files with 2665 additions and 718 deletions
@@ -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"
+16 -12
View File
@@ -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 */}
<Route
path="*"
element={
<AppProviders>
<AppLayout>
<HomePage />
<Onboarding />
</AppLayout>
</AppProviders>
}
/>
{/* The app, under a shared frame so the rail renders once outside it. */}
<Route element={<AppFrame />}>
{/* All other routes need AppProviders for backend integration */}
<Route
path="*"
element={
<AppProviders>
<AppLayout>
<HomePage />
<Onboarding />
</AppLayout>
</AppProviders>
}
/>
</Route>
</Routes>
</Suspense>
);
@@ -173,7 +173,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
mb="xs"
style={{ paddingLeft: "1rem" }}
>
{t("fileManager.myFiles", "My Files")}
{t("fileSidebar.myFiles", "File library")}
</Text>
{buttons}
</Stack>
@@ -121,7 +121,7 @@ export function FolderTreePanel({ active }: FolderTreePanelProps) {
<div className="folder-tree-panel-inner">
<div className="folder-tree-panel-header">
<span className="folder-tree-panel-title">
{t("filesPage.myFiles", "My Files")}
{t("fileSidebar.myFiles", "File library")}
</span>
</div>
@@ -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
@@ -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. */
@@ -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 (
<QuickNavHostProvider>
<div className="app-frame">
<QuickNavRailHost />
<div className="app-frame__content">
<Suspense fallback={<LoadingFallback />}>
<Outlet />
</Suspense>
</div>
</div>
</QuickNavHostProvider>
);
}
@@ -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 <Outlet />;
}
@@ -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;
@@ -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 <FileManagerView />;
@@ -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 && (
<div style={{ position: "absolute", top: 12, right: 12, zIndex: 20 }}>
<NotificationBell />
</div>
@@ -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;
}
}
@@ -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;
@@ -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 <MountedNotificationBell />;
@@ -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<HTMLDivElement>(null);
const headingId = useId();
// Where the new ones stop, frozen when the panel opens (opening marks everything read).
const [firstSeenId, setFirstSeenId] = useState<string | null>(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 (
<div className="notification-bell" ref={container}>
<Button
variant="quiet"
size="md"
shape="circle"
className="notification-bell__trigger"
// Read by the panel's outside-click handler.
data-notifications-trigger
aria-label={t("notifications.open", "Notifications")}
aria-expanded={open}
onClick={toggle}
onClick={() => setOpen((wasOpen) => !wasOpen)}
>
<BellIcon />
{unreadCount > 0 && (
@@ -119,53 +66,11 @@ function MountedNotificationBell() {
</Button>
{open && (
<div
className="notification-bell__panel"
role="dialog"
// Named by its own heading: a dialog with no accessible name is announced as just "dialog".
aria-labelledby={headingId}
<NotificationPanel
onClose={() => setOpen(false)}
registry={registry}
style={anchor ? { top: anchor.top, right: anchor.right } : undefined}
>
<h2 className="notification-bell__heading" id={headingId}>
{t("notifications.title", "Notifications")}
</h2>
{notifications.length === 0 ? (
<p className="notification-bell__empty">
{t("notifications.empty", "Nothing to report.")}
</p>
) : (
<ul className="notification-bell__list">
{notifications.map((notification, index) => (
<Fragment key={notification.id}>
{index === 0 && dividedAt > 0 && (
<li aria-hidden>
<DividerWithText
text={t("notifications.section.new", "New")}
/>
</li>
)}
{/* Only with something on both sides: a lone "Earlier" over everything says
nothing the empty badge has not. */}
{index === dividedAt && dividedAt > 0 && (
<li aria-hidden>
<DividerWithText
text={t("notifications.section.earlier", "Earlier")}
/>
</li>
)}
<NotificationItem
notification={notification}
unread={index < dividedAt}
documentState={documentStateFor(notification)}
registry={registry}
onDismissPanel={() => setOpen(false)}
/>
</Fragment>
))}
</ul>
)}
</div>
/>
)}
</div>
);
@@ -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<HTMLDivElement>(null);
const headingId = useId();
// Frozen on open, since opening marks them all read.
const [firstSeenId, setFirstSeenId] = useState<string | null>(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 (
<div
ref={panel}
className={
className
? `notification-bell__panel ${className}`
: "notification-bell__panel"
}
id={id}
role="dialog"
tabIndex={-1}
aria-labelledby={headingId}
style={style}
>
<h2 className="notification-bell__heading" id={headingId}>
{t("notifications.title", "Notifications")}
</h2>
{notifications.length === 0 ? (
<p className="notification-bell__empty">
{t("notifications.empty", "Nothing to report.")}
</p>
) : (
<ul className="notification-bell__list">
{notifications.map((notification, index) => (
<Fragment key={notification.id}>
{index === 0 && dividedAt > 0 && (
<li aria-hidden>
<DividerWithText
text={t("notifications.section.new", "New")}
/>
</li>
)}
{/* Only with unread rows above it. */}
{index === dividedAt && dividedAt > 0 && (
<li aria-hidden>
<DividerWithText
text={t("notifications.section.earlier", "Earlier")}
/>
</li>
)}
<NotificationItem
notification={notification}
unread={index < dividedAt}
documentState={documentStateFor(notification)}
registry={registry}
onDismissPanel={onClose}
/>
</Fragment>
))}
</ul>
)}
</div>
);
}
@@ -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 <BrandMark>, 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,
@@ -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 (
<Logo
variant={collapsed ? "iconOnly" : "iconAndText"}
iconHeight="1.6rem"
textHeight="1.3rem"
/>
);
}
@@ -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;
}
@@ -1,16 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
const meta: Meta<typeof BrandSwitcher> = {
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<typeof BrandSwitcher>;
export const Playground: Story = {};
@@ -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 (
<div className={`sui-brand-switcher${className ? ` ${className}` : ""}`}>
<Dropdown.Root align="start" open={open} onOpenChange={setOpen}>
<Dropdown.Trigger>
<Button
variant="quiet"
data-brandmark-morph
className={`sui-brand-switcher__trigger${open ? " is-open" : ""}`}
aria-label={t("portal.shell.sidebar.switchApp", "Switch app")}
leftSection={<BrandMark height="1.6rem" />}
>
{!collapsed && <Logo variant="textOnly" textHeight="1.3rem" />}
</Button>
</Dropdown.Trigger>
<Dropdown.Menu width="11rem">
<AppSwitchMenuItems current={current} onSwitch={onSwitch} />
</Dropdown.Menu>
</Dropdown.Root>
</div>
);
}
@@ -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 (
<svg
className={className}
viewBox="0 0 256 256"
fill="none"
style={size ? { width: size, height: size } : undefined}
aria-hidden
>
<rect width="256" height="256" rx="58" fill="var(--c-brand-mark)" />
<path
d="M39.2638 127.834L155.374 32L155.375 121.499L39.2638 217.333L39.2638 127.834Z"
fill="white"
/>
<path
d="M159 124.5L159 88.5L216.728 38.4472L216.728 128.052L100.479 224L100.479 172L159 124.5Z"
fill="white"
fillOpacity="0.6"
/>
</svg>
);
}
@@ -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 {
@@ -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<void>;
@@ -155,11 +156,12 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
collapsed = false,
onToggleCollapse,
onOpenSettings,
accountHoisted = false,
toggleAriaLabel,
toggleIcon,
onUploadFiles,
onPickGoogleDriveFiles,
extraAction,
toggleAriaLabel,
toggleIcon,
},
ref,
) {
@@ -249,7 +251,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
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<HTMLDivElement, FileSidebarProps>(
</div>
)}
<div className="file-sidebar-inner">
<div className="file-sidebar-brand">
<AppSwitcher collapsed={collapsed} />
{onToggleCollapse && (
<ActionIcon
variant="tertiary"
size="md"
className="file-sidebar-collapse-toggle"
onClick={() => onToggleCollapse()}
aria-label={
toggleAriaLabel ??
(collapsed
? t("fileSidebar.expand", "Expand sidebar")
: t("fileSidebar.collapse", "Collapse sidebar"))
}
>
{toggleIcon ?? <SidebarToggleIcon size={18} />}
</ActionIcon>
)}
</div>
<SidebarHeader
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
toggleAriaLabel={toggleAriaLabel}
toggleIcon={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<HTMLDivElement, FileSidebarProps>(
{/* 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. */}
<Tooltip
label={t("fileSidebar.openFromComputer", "Open from computer")}
@@ -1003,7 +991,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
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<HTMLDivElement, FileSidebarProps>(
)}
<Tooltip
label={t("fileSidebar.myFiles", "My Files")}
label={t("fileSidebar.myFiles", "File library")}
position="right"
withinPortal
disabled={!collapsed}
@@ -1094,7 +1082,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
}}
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<HTMLDivElement, FileSidebarProps>(
<FolderOpenIcon className="file-sidebar-action-icon" />
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("fileSidebar.myFiles", "My Files")}
{t("fileSidebar.myFiles", "File library")}
</span>
)}
</div>
@@ -1370,15 +1358,15 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
{/* Getting-started checklist, floating above the footer (SaaS only). */}
<SidebarChecklistSlot collapsed={collapsed} />
{/* Box 3 — the shared footer: credits, app switch, account row. */}
{/* Box 3 — the shared footer: credits, plan, and the account row unless hoisted. */}
<NavFooter
className="file-sidebar-footer-box"
displayName={displayName}
profilePictureUrl={profilePictureUrl}
onOpenSettings={onOpenSettings}
showAccount={!accountHoisted}
credits={credits}
onOpenPlan={openPlan ?? undefined}
otherApp={otherApp}
collapsed={collapsed}
/>
</div>
@@ -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 (
<div className={`file-sidebar-header${className ? ` ${className}` : ""}`}>
{!collapsed && <Logo variant="textOnly" textHeight="1.3rem" />}
{onToggleCollapse && (
<SidebarToggleButton
collapsed={collapsed}
onToggle={onToggleCollapse}
ariaLabel={toggleAriaLabel}
icon={toggleIcon}
/>
)}
</div>
);
}
@@ -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 (
<ActionIcon
variant="tertiary"
size="md"
className="file-sidebar-collapse-toggle"
onClick={() => onToggle()}
aria-label={
ariaLabel ??
(collapsed
? t("fileSidebar.expand", "Expand sidebar")
: t("fileSidebar.collapse", "Collapse sidebar"))
}
>
{icon ?? <SidebarToggleIcon size={18} />}
</ActionIcon>
);
}
@@ -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<TooltipProps["header"]>;
}) {
const { tooltipLogo } = useLogoAssets();
return (
<div className={styles["tooltip-header"]}>
<div className={styles["tooltip-logo"]}>
{header.logo || (
<img
src={tooltipLogo}
alt="Stirling PDF"
style={{ width: "1.4rem", height: "1.4rem", display: "block" }}
/>
)}
</div>
<span className={styles["tooltip-title"]}>{header.title}</span>
</div>
);
}
export const Tooltip: React.FC<TooltipProps> = ({
sidebarTooltip = false,
position,
@@ -85,7 +108,6 @@ export const Tooltip: React.FC<TooltipProps> = ({
const { t } = useTranslation();
const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const { tooltipLogo } = useLogoAssets();
const triggerRef = useRef<HTMLElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
@@ -105,9 +127,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
}, []);
// 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<TooltipProps> = ({
}
/>
)}
{header && (
<div className={styles["tooltip-header"]}>
<div className={styles["tooltip-logo"]}>
{header.logo || (
<img
src={tooltipLogo}
alt="Stirling PDF"
style={{ width: "1.4rem", height: "1.4rem", display: "block" }}
/>
)}
</div>
<span className={styles["tooltip-title"]}>{header.title}</span>
</div>
)}
{header && <TooltipHeader header={header} />}
<TooltipContent
content={content}
tips={tips}
@@ -14,13 +14,9 @@
column-gap: 8px;
min-height: 38px;
padding: 0 8px;
/* No left margin: the file sidebar's own 0.5rem padding already provides the
gutter on that side, so adding one here would double it and leave the bar
further from the sidebar than it is from the tool panel. */
margin: var(--nav-gutter) var(--nav-gutter) 0 0;
/* Flush, not a floating card: the same surface and hairline as the sidebars. */
background-color: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-nav);
border-bottom: 1px solid var(--c-border-subtle);
flex-shrink: 0;
z-index: 50;
}
@@ -151,15 +147,18 @@
/* ---- Tool buttons (own full-width row, always below the top row) ---- */
.workbench-bar-center {
order: 4;
flex: 0 0 100%;
/* Wider by the bar's paddings, cancelled by the negative margin, so the rule spans. */
flex: 0 0 calc(100% + 16px);
min-width: 0;
max-width: 100%;
max-width: calc(100% + 16px);
margin-inline: -8px;
position: relative;
display: flex;
align-items: center;
/* Symmetric side padding leaves room for the retract handle pinned right
without knocking the centred tool icons off-centre. */
padding: 4px 36px;
without knocking the centred tool icons off-centre. The extra 8px each side
absorbs the negative margin. */
padding: 4px 44px;
border-top: 1px solid var(--c-border-subtle);
}
@@ -327,6 +326,9 @@
}
.workbench-bar-center {
flex-basis: 100%;
max-width: 100%;
margin-inline: 0;
padding: 2px 4px 2px 8px;
}
@@ -57,7 +57,7 @@ import WorkbenchBarMobileActions from "@app/components/shared/workbenchBar/Workb
import WorkbenchBarToolbarHandle from "@app/components/shared/workbenchBar/WorkbenchBarToolbarHandle";
import { renderWithTooltip } from "@app/components/shared/workbenchBar/workbenchBarTooltip";
import { WorkbenchBarActionsProps } from "@app/components/shared/workbenchBar/types";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { useIsMobile, useIsPhone } from "@app/hooks/useIsMobile";
import "@app/components/shared/WorkbenchBar.css";
import { NotificationBell } from "@app/components/notifications/NotificationBell";
@@ -117,6 +117,8 @@ export default function WorkbenchBar({
const { sharingEnabled } = useSharingEnabled();
const viewerContext = React.useContext(ViewerContext);
const isMobile = useIsMobile();
// Below this width the rail, and the bell it carries, is hidden.
const isPhone = useIsPhone();
const [mobileToolsExpanded, setMobileToolsExpanded] = useState(false);
const selectors = useFileSelectors();
@@ -487,7 +489,7 @@ export default function WorkbenchBar({
data-wrapped="false"
data-tour="workbench-bar"
>
{/* Left: optional "Back to My Files" + view switcher */}
{/* Left: optional "Back to File library" + view switcher */}
<div className="workbench-bar-views" data-tour="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={<ArrowBackIcon style={{ fontSize: "1.1rem" }} />}
@@ -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")}
</span>
</Button>
<div className="workbench-bar-divider" />
@@ -603,9 +605,13 @@ export default function WorkbenchBar({
enforcingProgress={enforcingProgress}
/>
)}
{/* Last in the globals, so it is the rightmost control. */}
<div className="workbench-bar-divider workbench-bar-globals-sep" />
<NotificationBell />
{isPhone && (
<>
{/* Last in the globals, so it is the rightmost control. */}
<div className="workbench-bar-divider workbench-bar-globals-sep" />
<NotificationBell />
</>
)}
</div>
</div>
);
@@ -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: (
<Tooltip
label={accountLabel}
position="right"
withinPortal
disabled={!collapsed}
>
<button
type="button"
className="nav-footer__row nav-footer__account"
// Called with no args: handlers that take optional params (the
// processor's openSettings(section?)) must not receive the event.
onClick={onOpenSettings ? () => onOpenSettings() : undefined}
disabled={!onOpenSettings}
data-testid={onOpenSettings ? "config-button" : undefined}
data-tour={onOpenSettings ? "config-button" : undefined}
aria-label={accountLabel}
if (showAccount) {
rows.push({
key: "account",
node: (
<Tooltip
label={accountLabel}
position="right"
withinPortal
disabled={!collapsed}
>
{/* Decorative: the button's own label already names the account, so
an alt/label here would just repeat it to a screen reader. */}
<span aria-hidden>
<Avatar
size="sm"
name={displayName}
src={profilePictureUrl ?? undefined}
/>
</span>
{!collapsed && (
<span className="nav-footer__row-label sidebar-content-fade">
{displayName}
<button
type="button"
className="nav-footer__row nav-footer__account"
// Called with no args: a handler with an optional param must not get the event.
onClick={onOpenSettings ? () => onOpenSettings() : undefined}
disabled={!onOpenSettings}
data-testid={onOpenSettings ? "config-button" : undefined}
data-tour={onOpenSettings ? "config-button" : undefined}
aria-label={accountLabel}
>
{/* Decorative: the button's own label already names the account. */}
<span aria-hidden>
<Avatar
size="sm"
name={displayName}
src={profilePictureUrl ?? undefined}
/>
</span>
)}
{onOpenSettings && !collapsed && (
<span className="nav-footer__trailing" aria-hidden>
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
</span>
)}
</button>
</Tooltip>
),
});
{!collapsed && (
<span className="nav-footer__row-label sidebar-content-fade">
{displayName}
</span>
)}
{onOpenSettings && !collapsed && (
<span className="nav-footer__trailing" aria-hidden>
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
</span>
)}
</button>
</Tooltip>
),
});
}
if (rows.length === 0) return null;
return (
<NavSurface
@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next";
import { Tooltip } from "@app/components/shared/Tooltip";
import { BrandMark } from "@app/components/shared/BrandMark";
export interface QuickNavBrandProps {
/** Returns the app you are in to its default state. */
onReturnHome: () => void;
}
export function QuickNavBrand({ onReturnHome }: QuickNavBrandProps) {
const { t } = useTranslation();
const label = t("quickNav.home", "Stirling");
return (
<div className="quick-nav-brand">
<Tooltip content={label} position="right" arrow>
<button
type="button"
className="quick-nav-brand-button"
aria-label={label}
onClick={onReturnHome}
>
<BrandMark height="1.6rem" />
</button>
</Tooltip>
</div>
);
}
@@ -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 (
<NotificationPanel
id={NOTIFICATIONS_PANEL_ID}
onClose={closeNotifications}
registry={notificationActions}
className="notification-bell__panel--rail"
/>
);
}
@@ -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);
}
@@ -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;
}
@@ -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 (
<div className="quick-nav-rail-account">
<Tooltip content={label} position="right" arrow>
{/* A span, not the Avatar: Tooltip binds by cloning its child. */}
<span
className="quick-nav-rail-avatar-target"
data-testid="config-button"
data-tour="config-button"
>
<Avatar
src={profilePictureUrl ?? undefined}
name={displayName}
size="sm"
onClick={onOpenSettings}
ariaLabel={label}
className="quick-nav-rail-avatar"
/>
</span>
</Tooltip>
</div>
);
}
@@ -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> = {},
): 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(<QuickNavRailBase groups={groups} />),
);
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(
<QuickNavRailBase
groups={[
[PROCESSOR],
[entry("reader", { pressed: true }), entry("files")],
]}
/>,
),
);
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(
<QuickNavRailBase
groups={[
[
entry("automate", {
disabled: true,
reason: "Disabled by server administrator",
}),
],
]}
/>,
),
);
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(
<QuickNavRailBase
groups={[
[entry("processor", { disabled: true, reason: "no access" })],
WITHIN,
]}
/>,
),
);
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);
});
});
@@ -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<QuickNavEntry, "id">) {
return (
<Tooltip
content={disabled && reason ? `${label}${reason}` : label}
position="right"
arrow
>
<button
type="button"
className="quick-nav-rail-item"
aria-pressed={pressed}
aria-current={current ? "true" : undefined}
aria-label={label}
aria-haspopup={expanded === undefined ? undefined : "dialog"}
aria-expanded={expanded}
aria-controls={expanded === undefined ? undefined : controls}
// aria-disabled, not `disabled`: stays focusable, so its tooltip is reachable.
aria-disabled={disabled || undefined}
onClick={disabled ? undefined : onClick}
>
{icon}
{badge !== undefined && badge > 0 && (
<span
className="quick-nav-rail-badge"
data-tone={badgeTone}
aria-hidden="true"
>
{badge > 9 ? "9+" : badge}
</span>
)}
</button>
</Tooltip>
);
}
export function QuickNavRailBase({ groups, footer }: QuickNavRailBaseProps) {
const { t } = useTranslation();
const populated = groups.filter((entries) => entries.length > 0);
return (
<nav
className="quick-nav-rail"
aria-label={t("quickNav.landmark", "Quick navigation")}
>
{populated.map((group, index) => (
<div className="quick-nav-rail-group" key={group[0].id}>
{index > 0 && <hr className="quick-nav-rail-divider" />}
{group.map((entry) => (
<RailButton key={entry.id} {...entry} />
))}
</div>
))}
{footer}
</nav>
);
}
@@ -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;
}
}
@@ -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 (
<div className="quick-nav-rail-container">
<QuickNavBrand onReturnHome={onReturnHome} />
<NavSurface className="quick-nav-rail-surface">
<QuickNavRailBase
{...railProps}
footer={
// Always rendered: the bell lives here too, so gating the footer hides it.
<div className="quick-nav-rail-footer">
<QuickNavRailNotifications
onToggle={onToggleNotifications}
open={notificationsOpen}
/>
{onInvite && (
<RailButton
label={t("quickNav.invite", "Invite")}
icon={
<LocalIcon
icon="person-add-outline-rounded"
width="1.125rem"
height="1.125rem"
/>
}
onClick={onInvite}
/>
)}
{onOpenSettings && (
<QuickNavRailAccount
onOpenSettings={onOpenSettings}
identity={identity}
/>
)}
</div>
}
/>
</NavSurface>
</div>
);
}
@@ -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 ? (
<LocalIcon icon="memory-rounded" width={SIZE} height={SIZE} />
) : (
<LocalIcon icon="memory-outline-rounded" width={SIZE} height={SIZE} />
),
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 ? (
<LocalIcon icon="edit-outline-rounded" width={SIZE} height={SIZE} />
) : (
<LocalIcon icon="edit-rounded" width={SIZE} height={SIZE} />
),
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: (
<LocalIcon icon="folder-outline-rounded" width={SIZE} height={SIZE} />
),
onClick: () => go("/files"),
},
{
id: "reader",
label: t("quickNav.reader", "Reader"),
icon: (
<LocalIcon
icon="menu-book-outline-rounded"
width={SIZE}
height={SIZE}
/>
),
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: (
<LocalIcon icon="rebase-outline-rounded" width={SIZE} height={SIZE} />
),
...unusable("automate"),
onClick: () => openTool("automate", "/automate"),
},
{
id: "sharedSign",
label: t("home.sharedSign.title", "Shared Signing"),
icon: (
<LocalIcon icon="draw-outline-rounded" width={SIZE} height={SIZE} />
),
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 (
<QuickNavRailContainer
groups={HAS_PORTAL ? [apps, within] : [within]}
onReturnHome={returnHome}
identity={host?.identity ?? null}
onOpenSettings={host?.hasSettings ? openSettings : undefined}
onInvite={
// Spelt out: VIEW_PATHS lives in the portal, which core cannot import.
HAS_PORTAL && host?.portalAccess
? () => go(`${PORTAL_BASENAME}/users`)
: undefined
}
onToggleNotifications={() =>
host?.actions.current?.toggleNotifications?.()
}
notificationsOpen={host?.notificationsOpen}
/>
);
}
@@ -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(
<QuickNavRailNotifications onToggle={() => {}} />,
);
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(<QuickNavRailNotifications onToggle={() => {}} />);
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(
<QuickNavRailNotifications onToggle={onToggle} />,
);
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(<QuickNavRailNotifications />);
await waitFor(() => expect(fetchNotifications).toHaveBeenCalled());
const button = container.querySelector(".quick-nav-rail-item")!;
expect(() => fireEvent.click(button)).not.toThrow();
});
});
@@ -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 <MountedRailNotifications onToggle={onToggle} open={open} />;
}
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.
<span data-notifications-trigger>
<RailButton
label={t("quickNav.notifications", "Notifications")}
icon={
<LocalIcon
icon="notifications-outline-rounded"
width="1.125rem"
height="1.125rem"
/>
}
badge={unreadCount}
expanded={Boolean(open)}
controls={NOTIFICATIONS_PANEL_ID}
onClick={() => onToggle?.()}
/>
</span>
);
}
@@ -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<string, boolean>,
endpointDetails: {} as Record<string, { reason?: string }>,
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();
});
});
@@ -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<Record<ToolId, string[]>>;
// 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<Record<ToolId, Cause>>;
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<string, unknown>).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<string, boolean>,
endpointDetails: Record<string, { reason?: string | null }>,
): 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]);
}
@@ -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}
>
<ActionIcon
aria-label={tool.name}
@@ -206,7 +207,7 @@ export default function RightSidebar() {
/* Fixed width matches the expanded panel width so the inner content is
laid out at its final size from the moment it mounts. The outer
.tool-panel clips it (overflow-hidden) while it animates from the
collapsed 3.5rem width — text/icons stay put and just come into view
collapsed rail width — text/icons stay put and just come into view
instead of jiggling as space becomes available. */
style={{
opacity: 1,
@@ -19,12 +19,12 @@
user-select: none;
}
/* Flush, with a rule on the workbench side mirroring the file sidebar's. */
.tool-panel--floating {
margin: var(--nav-gutter) var(--nav-gutter) var(--nav-gutter) 0;
height: calc(100vh - (var(--nav-gutter) * 2));
/* Tracks the frame, which is dvh; vh would cut the bottom off on mobile. */
height: 100%;
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-nav);
border-inline-start: 1px solid var(--c-border-subtle);
}
.tool-panel__collapsed-strip {
@@ -185,23 +185,24 @@ export function useViewerWorkbenchBarButtons(
section: "top" as const,
order: 10,
render: ({ disabled }) => (
<Tooltip
content={searchLabel}
<Popover
position={tooltipPosition}
offset={12}
arrow
portalTarget={document.body}
withArrow
shadow="md"
offset={8}
opened={isSearchInterfaceVisible}
onClose={viewer.searchInterfaceActions.close}
>
<Popover
position={tooltipPosition}
withArrow
shadow="md"
offset={8}
opened={isSearchInterfaceVisible}
onClose={viewer.searchInterfaceActions.close}
>
<Popover.Target>
<div style={{ display: "inline-flex" }}>
<Popover.Target>
<div style={{ display: "inline-flex" }}>
{/* Inside the Popover: Tooltip binds by cloning, and Popover passes no ref on. */}
<Tooltip
content={searchLabel}
position={tooltipPosition}
offset={12}
arrow
portalTarget={document.body}
>
<ActionIcon
variant="tertiary"
className="workbench-bar-action-icon"
@@ -215,18 +216,18 @@ export function useViewerWorkbenchBarButtons(
height="1.25rem"
/>
</ActionIcon>
</div>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: "20rem" }}>
<SearchInterface
visible={isSearchInterfaceVisible}
onClose={viewer.searchInterfaceActions.close}
/>
</div>
</Popover.Dropdown>
</Popover>
</Tooltip>
</Tooltip>
</div>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: "20rem" }}>
<SearchInterface
visible={isSearchInterfaceVisible}
onClose={viewer.searchInterfaceActions.close}
/>
</div>
</Popover.Dropdown>
</Popover>
),
},
{
@@ -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<string, unknown> = {};
const view = render(
<QuickNavHostProvider>
<Probe onRead={(value) => (latest = value as Record<string, unknown>)} />
<App />
</QuickNavHostProvider>,
);
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(
<QuickNavHostProvider>
<Probe onRead={() => {}} />
</QuickNavHostProvider>,
);
// Re-read through a fresh probe in the same provider.
let after: Record<string, unknown> = {};
view.rerender(
<QuickNavHostProvider>
<Probe onRead={(value) => (after = value as Record<string, unknown>)} />
</QuickNavHostProvider>,
);
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(
<QuickNavHostProvider>
<Probe onRead={() => {}} />
<App />
<LoginRoute />
</QuickNavHostProvider>,
);
});
let during: Record<string, unknown> = {};
view.rerender(
<QuickNavHostProvider>
<Probe
onRead={(value) => (during = value as Record<string, unknown>)}
/>
<App />
<LoginRoute />
</QuickNavHostProvider>,
);
expect(during.chromeless).toBe(true);
});
it("brings the bar back when that route leaves", () => {
const { view } = setup();
act(() => {
view.rerender(
<QuickNavHostProvider>
<Probe onRead={() => {}} />
<App />
<LoginRoute />
</QuickNavHostProvider>,
);
});
let after: Record<string, unknown> = {};
act(() => {
view.rerender(
<QuickNavHostProvider>
<Probe
onRead={(value) => (after = value as Record<string, unknown>)}
/>
<App />
</QuickNavHostProvider>,
);
});
expect(after.chromeless).toBe(false);
});
});
@@ -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<Record<ToolId, string>>;
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<QuickNavHostActions>;
setData: (data: Partial<QuickNavHostData>) => 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<QuickNavHostValue | null>(null);
/** Outside both apps' providers, so each app registers what only it knows. */
export function QuickNavHostProvider({ children }: { children: ReactNode }) {
const [data, setDataState] = useState<QuickNavHostData>(EMPTY_DATA);
const [chromeless, setChromelessState] = useState(false);
const actions = useRef<QuickNavHostActions>({});
const setData = useCallback((next: Partial<QuickNavHostData>) => {
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<QuickNavHostValue>(
() => ({
...data,
chromeless,
actions,
setData,
setActions,
setChromeless,
}),
[data, chromeless, setData, setActions, setChromeless],
);
return (
<QuickNavHostContext.Provider value={value}>
{children}
</QuickNavHostContext.Provider>
);
}
export function useQuickNavHost(): QuickNavHostValue | null {
return useContext(QuickNavHostContext);
}
/** No-ops outside the provider. */
export function useRegisterQuickNavHost(
data: Partial<QuickNavHostData>,
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]);
}
@@ -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) {
@@ -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 });
},
+157 -34
View File
@@ -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<MobileView>("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 (
<div className="h-screen overflow-hidden">
<HomePageExtensions />
<QuickNavHostBridge
portalAccess={Boolean(otherApp)}
onOpenSettings={() => setConfigModalOpen(true)}
requestNavigation={requestNavigation}
readerMode={readerMode}
onSetReaderMode={setReaderMode}
onGoToDefaultState={goToDefaultState}
onSelectTool={handleToolSelect}
toolReasons={quickNavToolReasons}
/>
<FilesPageProvider>
{isMobile ? (
<div
@@ -577,39 +709,30 @@ export default function HomePage() {
className="flex-nowrap flex"
bg="var(--c-bg)"
>
<MyFilesAwareFileSidebar
ref={quickAccessRef}
active={navigationState.workbench === "myFiles"}
// /files always shows the rail collapsed - force it here so a
// deep-link/reload onto /files (no workbench transition) still
// collapses, and a manual expand can't stick.
collapsed={
navigationState.workbench === "myFiles" || fileSidebarCollapsed
}
toggleAriaLabel={
navigationState.workbench === "myFiles"
? t("fileSidebar.leaveMyFiles", "Leave My Files")
: undefined
}
// Back-arrow on /files; burger elsewhere.
toggleIcon={
navigationState.workbench === "myFiles" ? (
<ArrowBackIcon />
) : undefined
}
onToggleCollapse={() => {
if (navigationState.workbench === "myFiles") {
navigate(EDITOR_BASENAME);
return;
<div className="workspace-frame">
<MyFilesAwareFileSidebar
ref={quickAccessRef}
accountHoisted
toggleAriaLabel={
navigationState.workbench === "myFiles"
? t("fileSidebar.leaveMyFiles", "Leave File library")
: undefined
}
setFileSidebarCollapsed((c) => {
const next = !c;
writePersistedSidebarCollapsed(next);
return next;
});
}}
onOpenSettings={() => setConfigModalOpen(true)}
/>
toggleIcon={
navigationState.workbench === "myFiles" ? (
<ArrowBackIcon />
) : 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)}
/>
</div>
<FolderTreePanel active={navigationState.workbench === "myFiles"} />
<Workbench />
{!hideToolPanel && <RightSidebar />}
@@ -0,0 +1,2 @@
/** Whether this build ships the processor. Shadowed per build. */
export const HAS_PORTAL = false;
@@ -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"]')
@@ -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"]')
@@ -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,
+1 -1
View File
@@ -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);
@@ -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;
+2 -2
View File
@@ -2,8 +2,8 @@ import { forwardRef, type HTMLAttributes } from "react";
import "@app/ui/NavSurface.css";
export interface NavSurfaceProps extends HTMLAttributes<HTMLDivElement> {
/** 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";
}
/**
@@ -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,
@@ -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;
}
@@ -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<typeof vi.fn> {
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);
});
});
@@ -4,21 +4,20 @@ type ViewTransitionDoc = Document & {
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
};
/**
* 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<void> {
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();
@@ -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 (
<Logo
variant={collapsed ? "iconOnly" : "iconAndText"}
iconHeight="1.6rem"
textHeight="1.3rem"
/>
);
}
@@ -0,0 +1,2 @@
/** Desktop inherits proprietary's app but never ships the portal. */
export const HAS_PORTAL = false;
@@ -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 (
<div className="portal-shell">
<Sidebar />
{/* portalAccess: being here is proof the processor is available. */}
<QuickNavHostBridge portalAccess onOpenSettings={() => openSettings()} />
<div className="workspace-frame">
<Sidebar />
</div>
{mobileNavOpen && (
<div
className="portal-shell__scrim"
@@ -90,9 +99,12 @@ export function AppShell({ children }: { children: ReactNode }) {
<div className="portal-shell__main">
<MobileTopbar />
<PortalSearchBar />
<div className="portal-shell__notifications">
<NotificationBell />
</div>
{/* Phone only: above that the rail carries it, and this would be a second. */}
{isPhone && (
<div className="portal-shell__notifications">
<NotificationBell />
</div>
)}
<main className="portal-shell__view">{children}</main>
</div>
</div>
@@ -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 (
<svg
className="portal-editor-hero__mark"
viewBox="0 0 256 256"
fill="none"
aria-hidden
>
<rect width="256" height="256" rx="58" fill="var(--c-brand-mark)" />
<path
d="M39.2638 127.834L155.374 32L155.375 121.499L39.2638 217.333L39.2638 127.834Z"
fill="white"
/>
<path
d="M159 124.5L159 88.5L216.728 38.4472L216.728 128.052L100.479 224L100.479 172L159 124.5Z"
fill="white"
fillOpacity="0.6"
/>
</svg>
);
}
/** 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) {
>
<div className="portal-editor-hero__row">
<div className="portal-editor-hero__logo">
<StirlingMark />
<BrandTile className="portal-editor-hero__mark" />
</div>
<div className="portal-editor-hero__info">
@@ -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;
@@ -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;
}
}
@@ -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}
>
<div className="portal-sidebar__logo">
<BrandSwitcher
current="processor"
onSwitch={goToEditor}
collapsed={collapsed}
/>
<div className="portal-sidebar__header">
{!collapsed && <Logo variant="textOnly" textHeight="1.3rem" />}
<ActionIcon
variant="tertiary"
className="portal-sidebar__collapse"
aria-label={
collapsed
? t("fileSidebar.expand", "Expand sidebar")
: t("fileSidebar.collapse", "Collapse sidebar")
}
onClick={toggleSidebarCollapsed}
>
<SidebarToggleIcon size={18} />
</ActionIcon>
<SidebarToggleButton
collapsed={collapsed}
onToggle={toggleSidebarCollapsed}
/>
<ActionIcon
variant="tertiary"
@@ -158,10 +132,9 @@ export function Sidebar() {
className="portal-sidebar__footer"
displayName={displayName}
profilePictureUrl={profilePictureUrl}
onOpenSettings={openSettings}
showAccount={false}
credits={credits}
onOpenPlan={openPlan ?? undefined}
otherApp={{ app: "editor", onOpen: goToEditor }}
accountExtras={<LinkAccountFooterItem />}
collapsed={collapsed}
/>
+47 -33
View File
@@ -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. */}
<Route element={<AppFrame />}>
{/* 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. */}
<Route
path="*"
element={
<RootGate>
<AppProviders>
<AppLayout>
<Routes>
<Route path="/login" element={<Login />} />
{/* Self-hosted has no signup - accounts are created by an
admin. Old links land on login instead. */}
<Route
path="/signup"
element={<Navigate to="/login" replace />}
/>
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
<Route path="/share/:token" element={<ShareLinkPage />} />
{/* The editor and its tool routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<Onboarding />
{WATCHED_FOLDERS_ENABLED && <WatchedFoldersRegistration />}
</AppLayout>
</AppProviders>
</RootGate>
}
/>
{/* All other routes need AppProviders for backend integration. RootGate
routes "/" by role before any of it mounts. */}
<Route
path="*"
element={
<RootGate>
<AppProviders>
<AppLayout>
<Routes>
{/* Not the app: no rail over any of these, ever. */}
<Route element={<NoAppChrome />}>
<Route path="/login" element={<Login />} />
{/* Self-hosted has no signup: old links land on login. */}
<Route
path="/signup"
element={<Navigate to="/login" replace />}
/>
<Route
path="/auth/callback"
element={<AuthCallback />}
/>
<Route
path="/invite/:token"
element={<InviteAccept />}
/>
<Route
path="/share/:token"
element={<ShareLinkPage />}
/>
</Route>
{/* The editor and its tool routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<Onboarding />
{WATCHED_FOLDERS_ENABLED && <WatchedFoldersRegistration />}
</AppLayout>
</AppProviders>
</RootGate>
}
/>
</Route>
</Routes>
</Suspense>
);
@@ -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 (
<Logo
variant={collapsed ? "iconOnly" : "iconAndText"}
iconHeight="1.6rem"
textHeight="1.3rem"
/>
);
}
return (
<BrandSwitcher
current="editor"
onSwitch={otherApp.onOpen}
collapsed={collapsed}
/>
);
}
@@ -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<EntitySearchModule | null>(null);
const modRef = useRef<EntitySearchModule | null>(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(() => {
@@ -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
: [];
@@ -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);
@@ -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 };
@@ -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;
+58 -39
View File
@@ -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. */}
<Route element={<AppFrame />}>
{/* 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. */}
<Route
path="*"
element={
<RootGate>
<AppProviders
appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }}
>
<AppLayout>
<NonAuthBootstraps />
<ResumePendingConnect />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/auth/reset" element={<ResetPassword />} />
<Route path="/oauth/consent" element={<OAuthConsent />} />
{/* 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. */}
<Route path="/link" element={<ConnectApprove />} />
{/* 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. */}
<Route path="/share/:token" element={<ShareLinkPage />} />
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
</AppLayout>
</AppProviders>
</RootGate>
}
/>
{/* Everything else needs the auth/backend providers. RootGate routes "/"
by role before any of it mounts. */}
<Route
path="*"
element={
<RootGate>
<AppProviders
appConfigProviderProps={{
onConfigLoaded: handleConfigLoaded,
}}
>
<AppLayout>
<NonAuthBootstraps />
<ResumePendingConnect />
<Routes>
{/* Not the app: no rail over any of these, ever. */}
<Route element={<NoAppChrome />}>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route
path="/auth/callback"
element={<AuthCallback />}
/>
<Route path="/auth/reset" element={<ResetPassword />} />
<Route
path="/oauth/consent"
element={<OAuthConsent />}
/>
{/* 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. */}
<Route path="/link" element={<ConnectApprove />} />
{/* 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. */}
<Route
path="/share/:token"
element={<ShareLinkPage />}
/>
</Route>
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
</AppLayout>
</AppProviders>
</RootGate>
}
/>
</Route>
</Routes>
</Suspense>
);