diff --git a/frontend/editor/src/core/components/shared/AppSwitch.css b/frontend/editor/src/core/components/shared/AppSwitch.css
new file mode 100644
index 0000000000..c40080b1e9
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/AppSwitch.css
@@ -0,0 +1,27 @@
+/* Trigger: bare square icon button that blends into either sidebar's header. */
+.app-switch-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.25rem;
+ height: 1.25rem;
+ border: none;
+ background: none;
+ cursor: pointer;
+ border-radius: var(--radius-sm);
+ color: var(--color-text-4);
+ transition:
+ background var(--motion-fast),
+ color var(--motion-fast);
+}
+
+.app-switch-btn:hover {
+ background: var(--color-bg-hover);
+ color: var(--color-text-2);
+}
+
+.app-switch-icon {
+ width: 1rem;
+ height: 1.0625rem;
+ display: block;
+}
diff --git a/frontend/editor/src/core/components/shared/AppSwitch.tsx b/frontend/editor/src/core/components/shared/AppSwitch.tsx
new file mode 100644
index 0000000000..b713e75e7c
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/AppSwitch.tsx
@@ -0,0 +1,82 @@
+import { useTranslation } from "react-i18next";
+import { Button, Dropdown } from "@app/ui";
+import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
+import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
+import "@app/components/shared/AppSwitch.css";
+
+export type AppSwitchTarget = "editor" | "processor";
+
+function ChevronDownIcon() {
+ return (
+
+ );
+}
+
+interface AppSwitchProps {
+ /** The app this switcher is rendered in (shown as active in the menu). */
+ current: AppSwitchTarget;
+ /** Resolved color scheme; picks the brand mark for the menu items. */
+ theme: "light" | "dark";
+ /** Invoked with the selected app; only called for apps other than `current`. */
+ onSwitch: (app: AppSwitchTarget) => void;
+ className?: string;
+}
+
+/**
+ * The editor ⇄ processor app switcher (chevron button → app menu). The editor
+ * and portal sidebars render this same element so the two apps present one
+ * identical switcher; each host supplies its own theme source and navigation.
+ */
+export function AppSwitch({
+ current,
+ theme,
+ onSwitch,
+ className,
+}: AppSwitchProps) {
+ const { t } = useTranslation();
+ const mark = theme === "dark" ? markDark : markLight;
+ const apps: Array<{ id: AppSwitchTarget; label: string }> = [
+ {
+ id: "processor",
+ label: t("portal.shell.sidebar.appProcessor", "Processor"),
+ },
+ { id: "editor", label: t("portal.shell.sidebar.appEditor", "Editor") },
+ ];
+ return (
+
+
+
+
+
+ {apps.map((app) => (
+ onSwitch(app.id)}
+ leading={
}
+ >
+ {app.label}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/editor/src/core/components/shared/AppSwitcher.tsx b/frontend/editor/src/core/components/shared/AppSwitcher.tsx
new file mode 100644
index 0000000000..298a46e1ac
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/AppSwitcher.tsx
@@ -0,0 +1,8 @@
+/**
+ * Core stub for the sidebar app switcher. Builds that bundle the admin portal
+ * (proprietary/saas) shadow this with a real switcher; core has no portal, so
+ * there is nothing to switch to.
+ */
+export function AppSwitcher() {
+ return null;
+}
diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css
index d06982179e..0b495d723b 100644
--- a/frontend/editor/src/core/components/shared/FileSidebar.css
+++ b/frontend/editor/src/core/components/shared/FileSidebar.css
@@ -102,6 +102,18 @@
flex-shrink: 0;
}
+/* App switcher (portal builds only) sits at the far end of the header row.
+ The content-fade animation makes this span a stacking context, which would
+ trap the menu's z-index below later sidebar rows — elevate the span so the
+ open menu paints above them. */
+.file-sidebar-app-switch {
+ margin-inline-start: auto;
+ display: flex;
+ align-items: center;
+ position: relative;
+ z-index: var(--z-dropdown);
+}
+
/* ---- Search row ---- */
.file-sidebar-search-row {
display: flex;
diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx
index db3820350e..f6fb7f0ce9 100644
--- a/frontend/editor/src/core/components/shared/FileSidebar.tsx
+++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx
@@ -29,6 +29,7 @@ import {
import { accountService } from "@app/services/accountService";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
import { Wordmark } from "@app/components/shared/Wordmark";
+import { AppSwitcher } from "@app/components/shared/AppSwitcher";
import type { StirlingFileStub } from "@app/types/fileContext";
import MenuIcon from "@mui/icons-material/Menu";
import SearchIcon from "@mui/icons-material/Search";
@@ -679,6 +680,17 @@ const FileSidebar = forwardRef(
className="file-sidebar-brand-text sidebar-content-fade"
/>
)}
+ {!collapsed && (
+ // The header row itself toggles collapse; stop the switcher's
+ // clicks and key presses from reaching it.
+ e.stopPropagation()}
+ onKeyDown={(e) => e.stopPropagation()}
+ >
+
+
+ )}
diff --git a/frontend/editor/src/core/routes/portalBasename.ts b/frontend/editor/src/core/routes/portalBasename.ts
new file mode 100644
index 0000000000..70f05f6a27
--- /dev/null
+++ b/frontend/editor/src/core/routes/portalBasename.ts
@@ -0,0 +1,7 @@
+/**
+ * Base path where the admin portal route-set mounts inside the editor app
+ * (see adminRouteExtensions). Lives in core so any layer can reference the
+ * mount point without importing portal code — build flavors that ship no
+ * portal (core, desktop, prototypes) must never resolve @portal.
+ */
+export const PORTAL_BASENAME = "/portal";
diff --git a/frontend/editor/src/core/ui/Dropdown.css b/frontend/editor/src/core/ui/Dropdown.css
index c96aec1672..736371e6ae 100644
--- a/frontend/editor/src/core/ui/Dropdown.css
+++ b/frontend/editor/src/core/ui/Dropdown.css
@@ -36,6 +36,12 @@
font-size: 0.8125rem;
color: var(--color-text-2);
border-radius: var(--radius-sm);
+ /* Explicit button reset: hosts without a global button reset (e.g. the
+ editor) would otherwise show the UA's buttonface background and border. */
+ background: none;
+ border: none;
+ font-family: inherit;
+ cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
diff --git a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx
new file mode 100644
index 0000000000..9d7c2c095e
--- /dev/null
+++ b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx
@@ -0,0 +1,9 @@
+/**
+ * Desktop inherits proprietary's layers but does not ship the portal (see
+ * desktop/routes/adminRouteExtensions), so shadow the switcher back to empty —
+ * otherwise the desktop bundle would reference @portal via the proprietary
+ * switcher's imports.
+ */
+export function AppSwitcher() {
+ return null;
+}
diff --git a/frontend/editor/src/portal/auth/editorUrl.ts b/frontend/editor/src/portal/auth/editorUrl.ts
index 43eb466cb7..8f60292331 100644
--- a/frontend/editor/src/portal/auth/editorUrl.ts
+++ b/frontend/editor/src/portal/auth/editorUrl.ts
@@ -3,8 +3,15 @@
* bouncing non-admins out).
*
* Sourced from VITE_EDITOR_URL so it's configurable per deploy rather than
- * hardcoded. The committed default is "/" (production serves the editor at the
- * root on the same origin as the portal). For dev cross-app navigation to a
- * separately-running editor, set VITE_EDITOR_URL in editor/.env.local.
+ * hardcoded, falling back to "/" (the editor serves the portal at /portal on
+ * the same origin, so the root is the editor). For dev cross-app navigation to
+ * a separately-running editor, set VITE_EDITOR_URL in editor/.env.local.
*/
-export const EDITOR_URL = import.meta.env.VITE_EDITOR_URL;
+export const EDITOR_URL = import.meta.env.VITE_EDITOR_URL || "/";
+
+/**
+ * When the editor is at the root of this origin, the portal and editor are
+ * route-sets of one SPA — switching apps can be a client-side navigation
+ * instead of a full page load.
+ */
+export const EDITOR_IS_SAME_APP = EDITOR_URL === "/";
diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css
index c12b6debb0..fcc8c7e6a8 100644
--- a/frontend/editor/src/portal/components/Sidebar.css
+++ b/frontend/editor/src/portal/components/Sidebar.css
@@ -40,35 +40,12 @@
white-space: nowrap;
}
-/* App switcher (down-arrow → Portal / Editor) */
+/* App switcher (down-arrow → Portal / Editor); button and menu styling live
+ with the shared AppSwitch element. */
.portal-sidebar__app-switch {
margin-left: auto;
display: flex;
}
-.portal-sidebar__app-switch-btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 1.25rem;
- height: 1.25rem;
- border: none;
- background: none;
- cursor: pointer;
- border-radius: var(--radius-sm);
- color: var(--color-text-4);
- transition:
- background var(--motion-fast),
- color var(--motion-fast);
-}
-.portal-sidebar__app-switch-btn:hover {
- background: var(--color-bg-hover);
- color: var(--color-text-2);
-}
-.portal-sidebar__app-icon {
- width: 1rem;
- height: 1.0625rem;
- display: block;
-}
/* Nav body */
.portal-sidebar__nav {
diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx
index 486611248b..fed5d7f68e 100644
--- a/frontend/editor/src/portal/components/Sidebar.tsx
+++ b/frontend/editor/src/portal/components/Sidebar.tsx
@@ -1,5 +1,7 @@
-import { Button, Dropdown, NavItem } from "@app/ui";
+import { NavItem } from "@app/ui";
+import { AppSwitch } from "@app/components/shared/AppSwitch";
import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
import { useView, type ViewId } from "@portal/contexts/ViewContext";
import { useTier } from "@portal/contexts/TierContext";
import { useTheme } from "@portal/contexts/ThemeContext";
@@ -7,7 +9,7 @@ import { useUI } from "@portal/contexts/UIContext";
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
import { useAsync } from "@portal/hooks/useAsync";
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
-import { EDITOR_URL } from "@portal/auth/editorUrl";
+import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl";
import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
import {
@@ -22,7 +24,6 @@ import {
UsageIcon,
DocsIcon,
SettingsIcon,
- ChevronDownIcon,
} from "@portal/components/icons";
import "@portal/components/Sidebar.css";
@@ -114,6 +115,15 @@ export function Sidebar() {
const { theme } = useTheme();
const { openSettings } = useUI();
const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ // 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("/");
+ 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).
@@ -148,45 +158,12 @@ export function Sidebar() {
-
-
-
-
-
-
- }
- >
- {t("portal.shell.sidebar.appProcessor")}
-
- {
- window.location.href = EDITOR_URL;
- }}
- leading={
-
- }
- >
- {t("portal.shell.sidebar.appEditor")}
-
-
-
+