diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx
index cd0a838421..983d5213ed 100644
--- a/frontend/editor/src/core/pages/HomePage.tsx
+++ b/frontend/editor/src/core/pages/HomePage.tsx
@@ -499,6 +499,7 @@ export default function HomePage() {
gap={0}
h="100%"
className="flex-nowrap flex"
+ bg="var(--c-bg)"
>
{
test("should prevent XSS via search input", async ({ page }) => {
await loginAndSetup(page);
- // Step 1: Enter XSS payload in the search box
+ // Step 1: Open the search box (the tool panel header shows a search
+ // toggle; the field only mounts once it's pressed) and enter the payload
+ await page.getByRole("button", { name: /search tools/i }).click();
const searchBox = page.getByPlaceholder(/search|cari/i).first();
await searchBox.fill('"> ');
diff --git a/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts b/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts
index b351acb1be..30ecc8d779 100644
--- a/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts
@@ -54,10 +54,12 @@ test.describe("13. Language / Localization", () => {
// Step 5: Wait for page reload (language change triggers window.location.reload())
await page.waitForLoadState("domcontentloaded");
- // Step 6: Verify the UI text is in English
- await expect(page.getByPlaceholder(/search/i).first()).toBeVisible({
- timeout: 10000,
- });
+ // Step 6: Verify the UI text is in English. The tool search is a
+ // header toggle, so assert its English label rather than the field,
+ // which only mounts once the toggle is pressed.
+ await expect(
+ page.getByRole("button", { name: /search tools/i }).first(),
+ ).toBeVisible({ timeout: 10000 });
}
});
});
diff --git a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts
index c66f392fa1..b8efb283fc 100644
--- a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts
@@ -17,6 +17,14 @@ test.describe("2. Main Dashboard / Home Page", () => {
page.locator('[data-testid="config-button"]').first(),
).toBeVisible();
+ // Tool search sits behind a header toggle now, so assert the affordance
+ // AND that pressing it actually mounts a usable search field — dropping
+ // the second half would stop covering the input entirely.
+ const searchToggle = page
+ .getByRole("button", { name: /search tools/i })
+ .first();
+ await expect(searchToggle).toBeVisible();
+ await searchToggle.click();
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
await expect(
@@ -74,7 +82,10 @@ test.describe("2. Main Dashboard / Home Page", () => {
await page.goto("/");
- await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
+ // Tool search is a header toggle; the field mounts only once pressed.
+ await expect(
+ page.getByRole("button", { name: /search tools/i }).first(),
+ ).toBeVisible();
});
});
diff --git a/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts b/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts
index 397c86d605..7dfda74b79 100644
--- a/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts
@@ -1,13 +1,24 @@
+import type { Page } from "@playwright/test";
import { test, expect } from "@app/tests/helpers/stub-test-base";
+/**
+ * The tool panel header shows a search *toggle*; the field only mounts once
+ * it's pressed. Open it and hand back the focused input.
+ */
+async function openToolSearch(page: Page) {
+ await page.getByRole("button", { name: /search tools/i }).click();
+ const searchBox = page.getByPlaceholder(/search|cari/i).first();
+ await expect(searchBox).toBeVisible({ timeout: 5000 });
+ return searchBox;
+}
+
test.describe("3. Tool Search", () => {
test.describe("3.1 Search - Happy Path", () => {
test("should filter tools in real time based on search input", async ({
page,
}) => {
- // Step 1: Click on the search box
- const searchBox = page.getByPlaceholder(/search|cari/i).first();
- await searchBox.click();
+ // Step 1: Open the search box from the header toggle
+ const searchBox = await openToolSearch(page);
// Step 2: Type "merge"
await searchBox.fill("merge");
@@ -31,9 +42,8 @@ test.describe("3. Tool Search", () => {
test("should handle queries with no matching tools gracefully", async ({
page,
}) => {
- // Step 1: Click on the search box
- const searchBox = page.getByPlaceholder(/search|cari/i).first();
- await searchBox.click();
+ // Step 1: Open the search box from the header toggle
+ const searchBox = await openToolSearch(page);
// Step 2: Type xyznonexistent123
await searchBox.fill("xyznonexistent123");
@@ -61,7 +71,7 @@ test.describe("3. Tool Search", () => {
test.describe("3.3 Search - Special Characters", () => {
test("should sanitize search input against XSS", async ({ page }) => {
// Step 1: Type XSS payload into the search box
- const searchBox = page.getByPlaceholder(/search|cari/i).first();
+ const searchBox = await openToolSearch(page);
await searchBox.fill("");
// Step 2: Verify no script execution occurs (no alert dialog)
diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css
index ced2c5dcb6..c7b0ccb08f 100644
--- a/frontend/editor/src/core/theme/colors.css
+++ b/frontend/editor/src/core/theme/colors.css
@@ -5,9 +5,9 @@
:root,
[data-theme="light"],
html[data-app-theme="light"] {
- --c-bg: var(--p-gray-50);
+ --c-bg: var(--p-paper);
--c-bg-raised: var(--p-white);
- --c-surface: var(--p-white);
+ --c-surface: var(--p-snow);
--c-surface-raised: var(--p-white);
--c-surface-sunken: var(--p-gray-100);
--c-input-bg: var(--p-white);
@@ -15,12 +15,17 @@ html[data-app-theme="light"] {
--c-active: var(--p-gray-100);
--c-overlay: rgba(0, 0, 0, 0.5);
- --c-text: var(--p-gray-900);
+ --c-text: var(--p-ink);
--c-text-muted: var(--p-gray-600);
- --c-text-subtle: var(--p-gray-500);
+ --c-text-subtle: var(--p-gray-550);
--c-text-on-primary: var(--p-white);
- --c-border: var(--p-gray-250);
+ --c-btn-solid: var(--c-text);
+ --c-btn-inverse: var(--p-snow);
+ --c-btn-secondary: var(--c-btn-inverse);
+ --c-btn-secondary-border: var(--c-border);
+
+ --c-border: var(--p-c-f0f0f0);
--c-border-subtle: var(--p-gray-200);
--c-border-strong: var(--p-gray-400);
@@ -55,7 +60,8 @@ html[data-app-theme="light"] {
marks, vendor colours, categorical avatar dots, static illustrations,
and the multi-hue gradients on feature/upgrade/onboarding surfaces.
Named here so components reference a --c-* token, never a raw --p-*. */
- --c-brand-mark: var(--p-brand-red-650); /* Stirling logo mark fill */
+ --c-brand-mark: var(--p-brand-red-650);
+ --c-brand-mark-soft: var(--p-brand-red-400); /* Stirling logo mark fill */
--c-accent-stripe: var(--p-periwinkle-500); /* Stripe "connect" CTA */
/* Feature-accent hues (fixed) used as stops in multi-hue gradients. */
@@ -110,9 +116,9 @@ html[data-app-theme="light"] {
/* ── MIDNIGHT (original navy) — also the default portal/Storybook dark ────── */
[data-theme="dark"],
html[data-app-theme="midnight"] {
- --c-bg: var(--p-zinc-900);
+ --c-bg: var(--p-c-141416);
--c-bg-raised: var(--p-zinc-850);
- --c-surface: var(--p-zinc-800);
+ --c-surface: var(--p-c-1a1a1d);
--c-surface-raised: var(--p-zinc-650);
--c-surface-sunken: var(--p-zinc-850);
--c-input-bg: var(--p-zinc-650);
@@ -120,12 +126,16 @@ html[data-app-theme="midnight"] {
--c-active: var(--p-gray-800);
--c-overlay: rgba(0, 0, 0, 0.6);
- --c-text: var(--p-zinc-100);
+ --c-text: var(--p-snow);
--c-text-muted: var(--p-zinc-200);
--c-text-subtle: var(--p-zinc-300);
--c-text-on-primary: var(--p-white);
+ --c-btn-solid: var(--c-text);
+ --c-btn-inverse: var(--p-ink);
+ --c-btn-secondary: var(--p-c-1a1a1d);
+ --c-btn-secondary-border: var(--p-c-343439);
- --c-border: var(--p-zinc-650);
+ --c-border: var(--p-c-28282d);
--c-border-subtle: rgba(255, 255, 255, 0.05);
--c-border-strong: var(--p-zinc-500);
@@ -157,9 +167,9 @@ html[data-app-theme="custom"] {
--c-accent-fg: var(--c-primary);
/* Primary-tinted surfaces (light base). Neutralised by the default override. */
- --c-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-gray-50));
+ --c-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-paper));
--c-bg-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white));
- --c-surface: color-mix(in srgb, var(--c-primary) 3%, var(--p-white));
+ --c-surface: color-mix(in srgb, var(--c-primary) 3%, var(--p-snow));
--c-surface-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white));
--c-surface-sunken: color-mix(
in srgb,
@@ -169,7 +179,7 @@ html[data-app-theme="custom"] {
--c-input-bg: color-mix(in srgb, var(--c-primary) 2%, var(--p-white));
--c-hover: color-mix(in srgb, var(--c-primary) 9%, var(--p-gray-50));
--c-active: color-mix(in srgb, var(--c-primary) 13%, var(--p-gray-100));
- --c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-gray-250));
+ --c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-c-f0f0f0));
--c-border-subtle: color-mix(
in srgb,
var(--c-primary) 10%,
@@ -270,16 +280,20 @@ html[data-app-theme="custom"] {
/* ── DARK — editor dark theme: neutral text/borders/icons + accent-tinted surfaces (default override opts out). After :root so it wins for dark. ── */
html[data-app-theme="custom"][data-mantine-color-scheme="dark"] {
/* Neutral text / borders / overlay (not accent-tinted). */
- --c-text: var(--p-zinc-100);
+ --c-text: var(--p-snow);
--c-text-muted: var(--p-zinc-200);
--c-text-subtle: var(--p-zinc-300);
+ --c-btn-solid: var(--c-text);
+ --c-btn-inverse: var(--p-ink);
+ --c-btn-secondary: var(--p-c-1a1a1d);
+ --c-btn-secondary-border: var(--p-c-343439);
--c-border-strong: var(--p-zinc-500);
--c-overlay: rgba(0, 0, 0, 0.6);
/* Accent-tinted surfaces (dark base). Neutralised by the default override. */
- --c-bg: color-mix(in srgb, var(--c-primary) 8%, var(--p-zinc-950));
+ --c-bg: color-mix(in srgb, var(--c-primary) 8%, var(--p-c-141416));
--c-bg-raised: color-mix(in srgb, var(--c-primary) 9%, var(--p-zinc-850));
- --c-surface: color-mix(in srgb, var(--c-primary) 8%, var(--p-zinc-800));
+ --c-surface: color-mix(in srgb, var(--c-primary) 8%, var(--p-c-1a1a1d));
--c-surface-raised: color-mix(
in srgb,
var(--c-primary) 9%,
@@ -293,7 +307,7 @@ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] {
--c-input-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-zinc-900));
--c-hover: color-mix(in srgb, var(--c-primary) 12%, var(--p-zinc-750));
--c-active: color-mix(in srgb, var(--c-primary) 15%, var(--p-zinc-700));
- --c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-zinc-650));
+ --c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-c-28282d));
--c-border-subtle: color-mix(
in srgb,
var(--c-primary) 10%,
@@ -335,27 +349,27 @@ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] {
/* ── DEFAULT (no tint) — surfaces opt out of the accent tint (neutral white/grey light, zinc dark); --c-primary stays for buttons. Extra [data-accent="default"] beats the tinted blocks. ── */
html[data-app-theme="custom"][data-accent="default"] {
- --c-bg: var(--p-gray-50);
+ --c-bg: var(--p-paper);
--c-bg-raised: var(--p-white);
- --c-surface: var(--p-white);
+ --c-surface: var(--p-snow);
--c-surface-raised: var(--p-white);
--c-surface-sunken: var(--p-gray-100);
--c-input-bg: var(--p-white);
--c-hover: var(--p-gray-50);
--c-active: var(--p-gray-100);
- --c-border: var(--p-gray-250);
+ --c-border: var(--p-c-f0f0f0);
--c-border-subtle: var(--p-gray-200);
}
html[data-app-theme="custom"][data-accent="default"][data-mantine-color-scheme="dark"] {
- --c-bg: var(--p-zinc-950);
+ --c-bg: var(--p-c-141416);
--c-bg-raised: var(--p-zinc-850);
- --c-surface: var(--p-zinc-800);
+ --c-surface: var(--p-c-1a1a1d);
--c-surface-raised: var(--p-zinc-775);
--c-surface-sunken: var(--p-zinc-900);
--c-input-bg: var(--p-zinc-900);
--c-hover: var(--p-zinc-750);
--c-active: var(--p-zinc-700);
- --c-border: var(--p-zinc-650);
+ --c-border: var(--p-c-28282d);
--c-border-subtle: var(--p-zinc-700);
}
diff --git a/frontend/editor/src/core/theme/dimensions.css b/frontend/editor/src/core/theme/dimensions.css
index 8042fcade6..aa0a1b38cc 100644
--- a/frontend/editor/src/core/theme/dimensions.css
+++ b/frontend/editor/src/core/theme/dimensions.css
@@ -28,6 +28,10 @@
--radius-xl: 16px;
--radius-pill: 9999px;
+ --radius-nav: 0.625rem;
+ --nav-gutter: 0.5rem;
+ --nav-rail-w: 3.5rem;
+
/* ── Layout sizing ── */
--footer-height: 2rem;
--landing-stack-w: 224px;
@@ -51,6 +55,7 @@
--motion-base: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
--motion-slow: 0.3s ease;
--motion-enter: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
+ --motion-spring: 0.22s cubic-bezier(0.32, 0.72, 0, 1);
--fullscreen-anim-duration-in: 0.28s;
--fullscreen-anim-duration-out: 0.22s;
diff --git a/frontend/editor/src/core/theme/primitives.css b/frontend/editor/src/core/theme/primitives.css
index 6ea844e9ed..239658dfeb 100644
--- a/frontend/editor/src/core/theme/primitives.css
+++ b/frontend/editor/src/core/theme/primitives.css
@@ -11,6 +11,10 @@
--p-gray-300: #d1d5db;
--p-gray-400: #9ca3af;
--p-gray-500: #6b7280;
+ /* Subtle body text in light mode. gray-500 clears 4.5:1 on pure white but
+ only reaches 4.39:1 on the --p-paper canvas; this is the same hue nudged
+ dark enough to pass (4.87:1) while staying lighter than gray-600. */
+ --p-gray-550: #646b76;
--p-gray-600: #4b5563;
--p-gray-700: #374151;
--p-gray-800: #1f2937;
@@ -29,6 +33,10 @@
--p-zinc-300: #71717a;
--p-zinc-200: #a1a1aa;
--p-zinc-100: #f4f4f5;
+ --p-c-141416: #141416;
+ --p-c-1a1a1d: #1a1a1d;
+ --p-c-28282d: #28282d;
+ --p-c-343439: #343439;
--p-blue-400: #60a5fa;
--p-blue-500: #3b82f6;
--p-blue-600: #2563eb;
@@ -46,6 +54,7 @@
/* Brand-red + ai-accent scales, consumed by core/ui/accents.css. */
--p-brand-red-200: #d9a8a8;
--p-brand-red-300: #d98a8a;
+ --p-brand-red-400: #ad7373;
--p-brand-red-650: #8e3131;
--p-brand-red-700: #7a2929;
--p-brand-red-900: #5a2424;
@@ -84,6 +93,10 @@
--p-tint-blue: #eef1fb;
--p-tint-violet: #f6f4fc;
--p-tint-pink: #fbf4f7;
+ --p-paper: #f5f4f1;
+ --p-ink: #373530;
+ --p-snow: #fafafa;
+ --p-c-f0f0f0: #f0f0f0;
/* Notion-style procurement view palette. */
--p-notion-blue: #2383e2;
@@ -92,7 +105,6 @@
--p-notion-ink: #37352f;
--p-notion-gray: #9b9a97;
--p-notion-gray-strong: #787774;
- --p-notion-paper: #f5f4f1;
--p-notion-paper-2: #f0eee9;
--p-notion-border: #e3e1dc;
--p-notion-border-2: #eae8e3;
diff --git a/frontend/editor/src/core/tokens/tokens.css b/frontend/editor/src/core/tokens/tokens.css
index c36fae35fc..2fe1fc3b70 100644
--- a/frontend/editor/src/core/tokens/tokens.css
+++ b/frontend/editor/src/core/tokens/tokens.css
@@ -326,16 +326,6 @@
opacity: 0.5;
}
}
-@keyframes pulseRing {
- 0% {
- transform: scale(0.8);
- opacity: 1;
- }
- 100% {
- transform: scale(2.2);
- opacity: 0;
- }
-}
@keyframes spin {
to {
transform: rotate(360deg);
diff --git a/frontend/editor/src/core/ui/ActionIcon.tsx b/frontend/editor/src/core/ui/ActionIcon.tsx
index 682e4b41e5..ca8dd0b9ef 100644
--- a/frontend/editor/src/core/ui/ActionIcon.tsx
+++ b/frontend/editor/src/core/ui/ActionIcon.tsx
@@ -103,15 +103,22 @@ export const ActionIcon = forwardRef(
"--ai-hover-color": "var(--c-text)",
"--ai-bd": "1px solid transparent",
}
- : {
- "--ai-bg": "transparent",
- "--ai-hover": "var(--_tint)",
- "--ai-color": "var(--_text)",
- "--ai-bd":
- variant === "secondary"
- ? "1px solid var(--_bd)"
- : "1px solid transparent",
- };
+ : variant === "secondary"
+ ? {
+ // Filled when the accent defines --_solid-2 (default = inverse
+ // ink/snow); otherwise falls back to the outlined look.
+ "--ai-bg": "var(--_solid-2, transparent)",
+ "--ai-hover": "var(--_solid-2-hover, var(--_tint))",
+ "--ai-color": "var(--_on-2, var(--_text))",
+ "--ai-bd": "1px solid var(--_bd-2, var(--_bd))",
+ }
+ : {
+ // tertiary (ghost) — neutral text + hover for the default accent.
+ "--ai-bg": "transparent",
+ "--ai-hover": "var(--_tert-tint, var(--_tint))",
+ "--ai-color": "var(--_tert-text, var(--_text))",
+ "--ai-bd": "1px solid transparent",
+ };
// Loosely-typed alias so the polymorphic `component={as}` doesn't fight Mantine's typing.
const Comp = MantineActionIcon as ElementType;
diff --git a/frontend/editor/src/core/ui/Button.tsx b/frontend/editor/src/core/ui/Button.tsx
index 7d2ff6d457..8c04aa226c 100644
--- a/frontend/editor/src/core/ui/Button.tsx
+++ b/frontend/editor/src/core/ui/Button.tsx
@@ -193,15 +193,23 @@ const ButtonRoot = forwardRef(
"--button-hover-color": "var(--c-text)",
"--button-bd": "1px solid transparent",
}
- : {
- "--button-bg": "transparent",
- "--button-hover": "var(--_tint)",
- "--button-color": "var(--_text)",
- "--button-bd":
- variant === "secondary"
- ? "1px solid var(--_bd)"
- : "1px solid transparent",
- };
+ : variant === "secondary"
+ ? {
+ // Filled when the accent defines --_solid-2 (default = inverse
+ // ink/snow); otherwise falls back to the outlined look.
+ "--button-bg": "var(--_solid-2, transparent)",
+ "--button-hover": "var(--_solid-2-hover, var(--_tint))",
+ "--button-color": "var(--_on-2, var(--_text))",
+ "--button-bd": "1px solid var(--_bd-2, var(--_bd))",
+ }
+ : {
+ // tertiary (ghost) — neutral text + hover when the accent
+ // defines --_tert-* (default); otherwise the accent link colour.
+ "--button-bg": "transparent",
+ "--button-hover": "var(--_tert-tint, var(--_tint))",
+ "--button-color": "var(--_tert-text, var(--_text))",
+ "--button-bd": "1px solid transparent",
+ };
// Loosely-typed alias so the polymorphic `component={as}` doesn't fight Mantine's typing.
const Comp = MantineButton as ElementType;
diff --git a/frontend/editor/src/core/ui/ChatFABButton.css b/frontend/editor/src/core/ui/ChatFABButton.css
index 171abf8809..b68713aa1f 100644
--- a/frontend/editor/src/core/ui/ChatFABButton.css
+++ b/frontend/editor/src/core/ui/ChatFABButton.css
@@ -5,11 +5,10 @@
width: 56px;
height: 56px;
border-radius: 16px;
- border: none;
- background: var(--c-primary);
- color: var(--c-text-on-primary);
+ border: 1px solid var(--c-btn-secondary-border);
+ background: var(--c-btn-secondary);
cursor: pointer;
- box-shadow: 0 4px 16px color-mix(in srgb, var(--c-primary) 40%, transparent);
+ box-shadow: var(--shadow-md);
transition:
transform 180ms cubic-bezier(0.32, 0.72, 0, 1),
box-shadow 180ms ease;
@@ -20,7 +19,7 @@
.chat-fab-btn:hover {
transform: scale(1.09);
- box-shadow: 0 6px 22px color-mix(in srgb, var(--c-primary) 52%, transparent);
+ box-shadow: var(--shadow-lg);
}
.chat-fab-btn:active {
diff --git a/frontend/editor/src/core/ui/ChatFABButton.tsx b/frontend/editor/src/core/ui/ChatFABButton.tsx
index 5ba3c8113b..672bd7c1c4 100644
--- a/frontend/editor/src/core/ui/ChatFABButton.tsx
+++ b/frontend/editor/src/core/ui/ChatFABButton.tsx
@@ -1,4 +1,5 @@
import type { ButtonHTMLAttributes } from "react";
+import { BrandMark } from "@app/components/shared/BrandMark";
import "@app/ui/ChatFABButton.css";
export interface ChatFABButtonProps extends ButtonHTMLAttributes {
@@ -25,20 +26,10 @@ export function ChatFABButton({
return (
-
-
-
-
+ {/* Decorative: the button itself carries the accessible name. */}
+
+
+
{loading && !showTick && (
)}
diff --git a/frontend/editor/src/core/ui/Inline.stories.tsx b/frontend/editor/src/core/ui/Inline.stories.tsx
index 5149f36018..15d5d8c713 100644
--- a/frontend/editor/src/core/ui/Inline.stories.tsx
+++ b/frontend/editor/src/core/ui/Inline.stories.tsx
@@ -34,9 +34,7 @@ export const SpaceBetween: Story = {
}}
>
Pipeline name
-
- healthy
-
+ healthy
),
};
diff --git a/frontend/editor/src/core/ui/Logo.css b/frontend/editor/src/core/ui/Logo.css
new file mode 100644
index 0000000000..417275d1d8
--- /dev/null
+++ b/frontend/editor/src/core/ui/Logo.css
@@ -0,0 +1,33 @@
+/* Shared brand lockup (mark + "Stirling" wordmark). The wordmark toggles by
+ colour scheme so one component works in the editor and the portal. */
+.sui-logo {
+ display: inline-flex;
+ align-items: center;
+ line-height: 1;
+}
+
+.sui-logo--vertical {
+ flex-direction: column;
+ justify-content: center;
+}
+
+.sui-logo__mark,
+.sui-logo__wordmark {
+ display: block;
+ width: auto;
+}
+
+/* Light wordmark by default; dark wordmark only under a dark scheme. */
+.sui-logo__wordmark--dark {
+ display: none;
+}
+
+[data-mantine-color-scheme="dark"] .sui-logo__wordmark--light,
+[data-theme="dark"] .sui-logo__wordmark--light {
+ display: none;
+}
+
+[data-mantine-color-scheme="dark"] .sui-logo__wordmark--dark,
+[data-theme="dark"] .sui-logo__wordmark--dark {
+ display: block;
+}
diff --git a/frontend/editor/src/core/ui/Logo.stories.tsx b/frontend/editor/src/core/ui/Logo.stories.tsx
new file mode 100644
index 0000000000..097288f919
--- /dev/null
+++ b/frontend/editor/src/core/ui/Logo.stories.tsx
@@ -0,0 +1,48 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Logo } from "@app/ui/Logo";
+
+const meta: Meta = {
+ title: "Brand/Logo",
+ component: Logo,
+ parameters: { layout: "centered" },
+ args: { variant: "iconAndText", orientation: "horizontal" },
+ argTypes: {
+ variant: {
+ control: "inline-radio",
+ options: ["iconOnly", "iconAndText", "textOnly"],
+ },
+ orientation: {
+ control: "inline-radio",
+ options: ["horizontal", "vertical"],
+ },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Playground: Story = {};
+
+/** The three variants side by side (toggle Storybook's theme to see the
+ * wordmark track light/dark). */
+export const Variants: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+/** iconAndText stacked — as used in the workbench empty state. */
+export const Stacked: Story = {
+ render: () => (
+
+ ),
+};
diff --git a/frontend/editor/src/core/ui/Logo.tsx b/frontend/editor/src/core/ui/Logo.tsx
new file mode 100644
index 0000000000..a16d8d6001
--- /dev/null
+++ b/frontend/editor/src/core/ui/Logo.tsx
@@ -0,0 +1,89 @@
+import type { CSSProperties } from "react";
+import markUrl from "@app/assets/brand/branding-logo/logo-mark.svg";
+import wordmarkLightUrl from "@app/assets/brand/branding-logo/wordmark-light.svg";
+import wordmarkDarkUrl from "@app/assets/brand/branding-logo/wordmark-dark.svg";
+import "@app/ui/Logo.css";
+
+/** iconOnly = mark; textOnly = "Stirling" wordmark; iconAndText = both. */
+export type LogoVariant = "iconOnly" | "iconAndText" | "textOnly";
+
+interface LogoProps {
+ variant?: LogoVariant;
+ /** Layout for iconAndText: mark left of text, or stacked above it. */
+ orientation?: "horizontal" | "vertical";
+ /** Height of the mark (CSS length). */
+ iconHeight?: string;
+ /** Height of the wordmark (CSS length). */
+ textHeight?: string;
+ /** Gap between mark and wordmark. */
+ gap?: string;
+ className?: string;
+ style?: CSSProperties;
+ alt?: string;
+}
+
+/**
+ * Shared brand lockup used across editor + processor. The mark is theme-
+ * agnostic; the wordmark swaps light/dark via CSS so it tracks the active
+ * colour scheme in both the editor (data-mantine-color-scheme) and the portal
+ * (data-theme).
+ */
+export function Logo({
+ variant = "iconAndText",
+ orientation = "horizontal",
+ iconHeight = "1.75rem",
+ textHeight = "1rem",
+ gap = "0.5rem",
+ className,
+ style,
+ alt = "Stirling",
+}: LogoProps) {
+ const showIcon = variant === "iconOnly" || variant === "iconAndText";
+ const showText = variant === "textOnly" || variant === "iconAndText";
+
+ const cls = [
+ "sui-logo",
+ orientation === "vertical" ? "sui-logo--vertical" : "",
+ className ?? "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ // Layout set inline so a consumer's className can't restack the lockup.
+ const layoutStyle: CSSProperties = {
+ display: orientation === "vertical" ? "flex" : "inline-flex",
+ flexDirection: orientation === "vertical" ? "column" : "row",
+ alignItems: "center",
+ gap,
+ };
+
+ return (
+
+ {showIcon && (
+
+ )}
+ {showText && (
+ <>
+
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/editor/src/core/ui/MetricStrip.css b/frontend/editor/src/core/ui/MetricStrip.css
index 472fce713e..8344fe1393 100644
--- a/frontend/editor/src/core/ui/MetricStrip.css
+++ b/frontend/editor/src/core/ui/MetricStrip.css
@@ -1,17 +1,79 @@
-.sui-metric-strip {
+.sui-metric-strip--grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
}
@media (max-width: 50rem) {
- .sui-metric-strip {
+ .sui-metric-strip--grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 30rem) {
- .sui-metric-strip {
+ .sui-metric-strip--grid {
grid-template-columns: 1fr;
}
}
+
+.sui-metric-strip--row {
+ display: flex;
+ align-items: center;
+ gap: 1.75rem;
+ padding: 0.625rem 1.25rem;
+ background: var(--c-surface);
+ border: 1px solid var(--c-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+}
+
+.sui-metric-strip__leading {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-shrink: 0;
+ padding-right: 1.25rem;
+ border-right: 1px solid var(--c-border-subtle);
+ align-self: stretch;
+ /* Neutral (ink) tint for a currentColor icon in the leading slot. */
+ color: var(--c-text);
+}
+
+.sui-metric-strip--row .sui-metric {
+ flex: 1 1 0;
+ min-width: 0;
+ padding: 0;
+ gap: 0;
+ background: none;
+ border: none;
+ box-shadow: none;
+}
+.sui-metric-strip--row .sui-metric__label {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--c-text-subtle);
+}
+.sui-metric-strip--row .sui-metric__value {
+ font-size: 1.0625rem;
+ font-weight: 600;
+}
+.sui-metric-strip--row .sui-metric__footer {
+ font-size: 0.6875rem;
+}
+
+@media (max-width: 50rem) {
+ .sui-metric-strip--row {
+ flex-wrap: wrap;
+ gap: 1.25rem 2rem;
+ }
+ .sui-metric-strip__leading {
+ border-right: none;
+ padding-right: 0;
+ flex-basis: 100%;
+ }
+ .sui-metric-strip--row .sui-metric {
+ flex-basis: 40%;
+ }
+}
diff --git a/frontend/editor/src/core/ui/MetricStrip.stories.tsx b/frontend/editor/src/core/ui/MetricStrip.stories.tsx
index 0c1ba975f2..615af0e0e2 100644
--- a/frontend/editor/src/core/ui/MetricStrip.stories.tsx
+++ b/frontend/editor/src/core/ui/MetricStrip.stories.tsx
@@ -2,6 +2,25 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { MetricStrip } from "@app/ui/MetricStrip";
import { MetricCard } from "@app/ui/MetricCard";
+function ShieldIcon() {
+ return (
+
+
+
+
+ );
+}
+
const meta: Meta = {
title: "Layout/MetricStrip",
component: MetricStrip,
@@ -22,3 +41,30 @@ export const Default: Story = {
),
};
+
+export const Row: Story = {
+ render: () => (
+ }>
+
+
+
+
+
+ ),
+};
diff --git a/frontend/editor/src/core/ui/MetricStrip.tsx b/frontend/editor/src/core/ui/MetricStrip.tsx
index 27ca2fc30a..b9e2a03bb3 100644
--- a/frontend/editor/src/core/ui/MetricStrip.tsx
+++ b/frontend/editor/src/core/ui/MetricStrip.tsx
@@ -3,21 +3,35 @@ import "@app/ui/MetricStrip.css";
export interface MetricStripProps {
children: ReactNode;
+ layout?: "grid" | "row";
+ leading?: ReactNode;
className?: string;
}
/**
- * Responsive grid wrapper for a row of {@link MetricCard}s — the prototype's
- * "metric strip" (Home, Sources, Usage, Infrastructure all use it). Four-up on
- * wide screens, two-up below 50rem.
+ * Wrapper for a set of {@link MetricCard}s. In the default `grid` layout it's
+ * the prototype's four-up "metric strip" (Home, Sources, Usage,
+ * Infrastructure). In `row` layout the cards render as inline columns inside a
+ * single bordered strip with an optional leading logo/title section.
*/
-export function MetricStrip({ children, className }: MetricStripProps) {
+export function MetricStrip({
+ children,
+ layout = "grid",
+ leading,
+ className,
+}: MetricStripProps) {
+ const classes = [
+ "sui-metric-strip",
+ `sui-metric-strip--${layout}`,
+ className ?? "",
+ ]
+ .filter(Boolean)
+ .join(" ");
return (
-
+
+ {layout === "row" && leading != null && (
+
{leading}
+ )}
{children}
);
diff --git a/frontend/editor/src/core/ui/NavItem.tsx b/frontend/editor/src/core/ui/NavItem.tsx
index b912d1fd19..ce86503cc0 100644
--- a/frontend/editor/src/core/ui/NavItem.tsx
+++ b/frontend/editor/src/core/ui/NavItem.tsx
@@ -52,6 +52,7 @@ export function NavItem({
.join(" ")}
data-accent={accent}
aria-current={isActive ? "page" : undefined}
+ aria-label={label}
>
{icon && (
diff --git a/frontend/editor/src/core/ui/NavSurface.css b/frontend/editor/src/core/ui/NavSurface.css
new file mode 100644
index 0000000000..5424f3469a
--- /dev/null
+++ b/frontend/editor/src/core/ui/NavSurface.css
@@ -0,0 +1,5 @@
+.sui-nav-surface {
+ background: var(--c-surface);
+ border: 1px solid var(--c-border-subtle);
+ border-radius: var(--radius-nav);
+}
diff --git a/frontend/editor/src/core/ui/NavSurface.stories.tsx b/frontend/editor/src/core/ui/NavSurface.stories.tsx
new file mode 100644
index 0000000000..8591887e2d
--- /dev/null
+++ b/frontend/editor/src/core/ui/NavSurface.stories.tsx
@@ -0,0 +1,62 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { NavSurface } from "@app/ui/NavSurface";
+import { NavItem } from "@app/ui/NavItem";
+
+function Dot() {
+ return (
+
+ );
+}
+
+const meta: Meta = {
+ title: "Primitives/NavSurface",
+ component: NavSurface,
+ parameters: { layout: "padded" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+/** The box a sidebar's rows sit in. NavItem's own states are covered by its
+ * own stories - the active row's accent-on-tint contrast is a known
+ * NavItem issue, and duplicating it here would just baseline it twice. */
+export const Default: Story = {
+ args: {
+ children: (
+
+ } />
+ } />
+ } />
+
+ ),
+ },
+};
+
+/** `as` renders a landmark element instead of a div. */
+export const AsSection: Story = {
+ args: {
+ as: "section",
+ "aria-label": "Processor",
+ children: (
+
+
+ Any content, not just nav rows.
+
+
+ ),
+ },
+};
diff --git a/frontend/editor/src/core/ui/NavSurface.tsx b/frontend/editor/src/core/ui/NavSurface.tsx
new file mode 100644
index 0000000000..38947fb91f
--- /dev/null
+++ b/frontend/editor/src/core/ui/NavSurface.tsx
@@ -0,0 +1,30 @@
+import { forwardRef, type HTMLAttributes } from "react";
+import "@app/ui/NavSurface.css";
+
+export interface NavSurfaceProps extends HTMLAttributes {
+ /** Element to render; `section`/`aside` when the box is a landmark. */
+ as?: "div" | "section" | "aside";
+}
+
+/**
+ * The floating box a sidebar's contents sit in: nav sections, the editor's
+ * file rail, the account footer. Surface fill, hairline border, nav radius.
+ */
+export const NavSurface = forwardRef(
+ function NavSurface(
+ { as: Component = "div", className, children, ...rest },
+ ref,
+ ) {
+ return (
+
+ {children}
+
+ );
+ },
+);
diff --git a/frontend/editor/src/core/ui/PanelHeader.stories.tsx b/frontend/editor/src/core/ui/PanelHeader.stories.tsx
index 715adbab65..261d98be2e 100644
--- a/frontend/editor/src/core/ui/PanelHeader.stories.tsx
+++ b/frontend/editor/src/core/ui/PanelHeader.stories.tsx
@@ -50,9 +50,7 @@ export const WithActions: Story = {
accent: "purple",
actions: (
<>
-
- Healthy
-
+ Healthy
Edit composition
diff --git a/frontend/editor/src/core/ui/StatusBadge.css b/frontend/editor/src/core/ui/StatusBadge.css
index 230c99b40d..4f64737b32 100644
--- a/frontend/editor/src/core/ui/StatusBadge.css
+++ b/frontend/editor/src/core/ui/StatusBadge.css
@@ -2,35 +2,21 @@
display: inline-flex;
align-items: center;
gap: 0.375rem;
- border-radius: var(--radius-pill);
font-family: var(--font-sans);
font-weight: 500;
letter-spacing: 0.01em;
- border: 1px solid transparent;
line-height: 1;
color: var(--sui-status-c, var(--c-text-subtle));
- background: color-mix(
- in srgb,
- var(--sui-status-c, var(--c-text-subtle)) 12%,
- transparent
- );
- border-color: color-mix(
- in srgb,
- var(--sui-status-c, var(--c-text-subtle)) 28%,
- transparent
- );
}
+
.sui-status--sm {
font-size: 0.6875rem;
- padding: 0.125rem 0.5rem;
}
.sui-status--md {
font-size: 0.75rem;
- padding: 0.1875rem 0.625rem;
}
.sui-status--lg {
font-size: 0.8125rem;
- padding: 0.3125rem 0.75rem;
}
.sui-status__dot {
@@ -38,25 +24,40 @@
height: 0.375rem;
border-radius: 50%;
background: currentColor;
- position: relative;
-}
-.sui-status__dot--pulse::after {
- content: "";
- position: absolute;
- inset: -0.125rem;
- border-radius: 50%;
- border: 2px solid currentColor;
- animation: pulseRing 1.4s ease-out infinite;
}
-/* Neutral keeps the plain muted surface rather than an accent tint. */
+.sui-status--pill {
+ border-radius: var(--radius-pill);
+ border: 1px solid
+ color-mix(
+ in srgb,
+ var(--sui-status-c, var(--c-text-subtle)) 28%,
+ transparent
+ );
+ background: color-mix(
+ in srgb,
+ var(--sui-status-c, var(--c-text-subtle)) 12%,
+ transparent
+ );
+}
+.sui-status--pill.sui-status--sm {
+ padding: 0.125rem 0.5rem;
+}
+.sui-status--pill.sui-status--md {
+ padding: 0.1875rem 0.625rem;
+}
+.sui-status--pill.sui-status--lg {
+ padding: 0.3125rem 0.75rem;
+}
+
.sui-status--neutral {
color: var(--c-text-subtle);
+}
+.sui-status--pill.sui-status--neutral {
background: var(--c-surface-sunken);
border-color: var(--c-border-subtle);
}
-/* Tones only pick the accent; the base rule builds the fill + border. `-dark`
- is theme-adaptive, so text stays legible on the pale fill in both themes. */
+
.sui-status--success {
--sui-status-c: var(--color-green-dark);
}
diff --git a/frontend/editor/src/core/ui/StatusBadge.stories.tsx b/frontend/editor/src/core/ui/StatusBadge.stories.tsx
index 8b77c58e41..9edeb00717 100644
--- a/frontend/editor/src/core/ui/StatusBadge.stories.tsx
+++ b/frontend/editor/src/core/ui/StatusBadge.stories.tsx
@@ -33,7 +33,7 @@ export const AllTones: Story = {
};
export const Live: Story = {
- args: { tone: "success", pulse: true, children: "Live" },
+ args: { tone: "success", children: "Live" },
};
export const Sizes: Story = {
diff --git a/frontend/editor/src/core/ui/StatusBadge.tsx b/frontend/editor/src/core/ui/StatusBadge.tsx
index 5ffd5d44f9..7d80819edd 100644
--- a/frontend/editor/src/core/ui/StatusBadge.tsx
+++ b/frontend/editor/src/core/ui/StatusBadge.tsx
@@ -14,28 +14,21 @@ export type StatusSize = "sm" | "md" | "lg";
export interface StatusBadgeProps {
tone?: StatusTone;
size?: StatusSize;
- /** Show a leading coloured dot. */
showDot?: boolean;
- /** Render the dot with a pulse animation (active / live indicator). */
- pulse?: boolean;
children?: ReactNode;
className?: string;
}
-/**
- * Inline status pill used across surfaces — pipeline rows, document status,
- * deployments, audit logs. Tone maps to semantic meaning, not raw colour.
- */
export function StatusBadge({
tone = "neutral",
size = "md",
showDot = true,
- pulse = false,
children,
className,
}: StatusBadgeProps) {
const cls = [
"sui-status",
+ showDot ? "" : "sui-status--pill",
`sui-status--${tone}`,
`sui-status--${size}`,
className ?? "",
@@ -44,12 +37,7 @@ export function StatusBadge({
.join(" ");
return (
- {showDot && (
-
- )}
+ {showDot && }
{children}
);
diff --git a/frontend/editor/src/core/ui/accents.css b/frontend/editor/src/core/ui/accents.css
index 404f98cc52..cf83edacaf 100644
--- a/frontend/editor/src/core/ui/accents.css
+++ b/frontend/editor/src/core/ui/accents.css
@@ -2,16 +2,26 @@
* accents derive from --color-* tokens (auto dark), neutral/brand/ai are explicit. */
.sui-acc-default {
- --_solid: var(--c-primary);
- --_solid-hover: var(--c-primary-hover);
- --_on: #ffffff;
+ --_solid: var(--c-btn-solid);
+ --_solid-hover: color-mix(
+ in srgb,
+ var(--c-btn-solid) 85%,
+ var(--c-btn-inverse)
+ );
+ --_on: var(--c-btn-inverse);
--_text: var(--c-primary-hover);
--_bd: color-mix(in srgb, var(--c-primary) 38%, var(--c-surface));
--_tint: color-mix(in srgb, var(--c-primary) 12%, transparent);
-}
-
-html[data-app-theme="custom"] .sui-acc-default {
- --_on: var(--c-text-on-primary);
+ --_solid-2: var(--c-btn-secondary);
+ --_solid-2-hover: color-mix(
+ in srgb,
+ var(--c-btn-secondary) 92%,
+ var(--c-btn-solid)
+ );
+ --_on-2: var(--c-btn-solid);
+ --_bd-2: var(--c-btn-secondary-border);
+ --_tert-text: var(--c-text);
+ --_tert-tint: var(--c-hover);
}
/* Danger is pinned to a fixed deep red (not the theme-lightened coral), so the
fill and the outline/text are the SAME red in both light and dark. */
diff --git a/frontend/editor/src/core/ui/index.ts b/frontend/editor/src/core/ui/index.ts
index d94dfab813..45212d8038 100644
--- a/frontend/editor/src/core/ui/index.ts
+++ b/frontend/editor/src/core/ui/index.ts
@@ -1,5 +1,6 @@
export * from "@app/ui/Button";
export * from "@app/ui/ActionIcon";
+export * from "@app/ui/Logo";
export * from "@app/ui/FilePicker";
export * from "@app/ui/SegmentedControl";
export * from "@app/ui/StatusBadge";
@@ -8,6 +9,7 @@ export * from "@app/ui/ToggleSwitch";
export * from "@app/ui/ProgressBar";
export * from "@app/ui/MetricCard";
export * from "@app/ui/NavItem";
+export * from "@app/ui/NavSurface";
export * from "@app/ui/PanelHeader";
export * from "@app/ui/CodeBlock";
export * from "@app/ui/SectionDivider";
diff --git a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx
index 9d7c2c095e..21896d9a1d 100644
--- a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx
+++ b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx
@@ -1,9 +1,18 @@
+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 shadow the switcher back to empty —
- * otherwise the desktop bundle would reference @portal via the proprietary
- * switcher's imports.
+ * 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() {
- return null;
+export function AppSwitcher({ collapsed }: AppSwitcherProps) {
+ return (
+
+ );
}
diff --git a/frontend/editor/src/portal/components/AppShell.css b/frontend/editor/src/portal/components/AppShell.css
index 6535832399..152c45f7c5 100644
--- a/frontend/editor/src/portal/components/AppShell.css
+++ b/frontend/editor/src/portal/components/AppShell.css
@@ -43,9 +43,6 @@
}
.portal-shell__topbar-wordmark {
- height: 1.375rem;
- width: auto;
- display: block;
margin-right: auto;
}
diff --git a/frontend/editor/src/portal/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx
index c50b4000e9..77c8f90686 100644
--- a/frontend/editor/src/portal/components/AppShell.tsx
+++ b/frontend/editor/src/portal/components/AppShell.tsx
@@ -3,11 +3,9 @@ import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { ActionIcon } from "@app/ui";
import { Sidebar } from "@portal/components/Sidebar";
-import { useTheme } from "@portal/contexts/ThemeContext";
import { useUI } from "@portal/contexts/UIContext";
import { MenuIcon, SearchIcon } from "@portal/components/icons";
-import wordmarkLight from "@app/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg";
-import wordmarkDark from "@app/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg";
+import { Logo } from "@app/ui/Logo";
import "@portal/components/AppShell.css";
/**
@@ -17,7 +15,6 @@ import "@portal/components/AppShell.css";
*/
function MobileTopbar() {
const { t } = useTranslation();
- const { theme } = useTheme();
const { mobileNavOpen, toggleMobileNav, openSearch } = useUI();
return (
@@ -30,10 +27,11 @@ function MobileTopbar() {
>
-
diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css
index 40e1fd25f5..dfb533f8ab 100644
--- a/frontend/editor/src/portal/components/Sidebar.css
+++ b/frontend/editor/src/portal/components/Sidebar.css
@@ -2,13 +2,32 @@
width: 15rem;
height: 100vh;
height: 100dvh; /* track mobile browser chrome */
- background: var(--c-bg-raised);
- border-right: 1px solid var(--c-border);
+ background: var(--c-bg);
display: flex;
flex-direction: column;
flex-shrink: 0;
position: sticky;
top: 0;
+ /* Slide between the full rail and the collapsed icon rail. Overridden by the
+ drawer's transform transition under the mobile breakpoint below. */
+ transition: width var(--motion-spring);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .portal-sidebar {
+ transition: none;
+ }
+}
+
+/* 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;
}
/* Mobile close button (shared ActionIcon) — only shown inside the drawer. */
@@ -17,6 +36,11 @@
flex-shrink: 0;
}
+.portal-sidebar__collapse {
+ margin-left: auto;
+ flex-shrink: 0;
+}
+
/* Off-canvas drawer under the shell breakpoint (keep in sync with
AppShell.css and Sidebar.tsx). Slides from the inline-start edge so RTL
locales get the mirrored behavior for free. */
@@ -46,58 +70,73 @@
.portal-sidebar__close {
display: inline-flex;
}
+ /* Collapse is a desktop affordance; the drawer is full-width on mobile. */
+ .portal-sidebar__collapse {
+ display: none;
+ }
+}
+
+/* ---- 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;
+}
+.portal-sidebar[data-collapsed] .portal-sidebar__nav {
+ padding-inline: 0.375rem;
+}
+.portal-sidebar[data-collapsed] .portal-sidebar__section {
+ padding-inline: 0;
+ align-items: center;
+}
+.portal-sidebar[data-collapsed] .portal-sidebar__section-label {
+ display: none;
+}
+.portal-sidebar[data-collapsed] .sui-navitem__label,
+.portal-sidebar[data-collapsed] .sui-navitem__trailing {
+ display: none;
+}
+.portal-sidebar[data-collapsed] .portal-sidebar__navtip {
+ display: flex;
+}
+.portal-sidebar[data-collapsed] .sui-navitem {
+ justify-content: center;
+ 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;
+}
+.portal-sidebar[data-collapsed] .portal-sidebar__footer {
+ margin-inline: 0.375rem;
+ padding-inline: 0;
+ align-items: center;
}
-/* Logo block */
.portal-sidebar__logo {
height: 3.1875rem; /* 51px */
padding: 0 0.875rem;
display: flex;
align-items: center;
- gap: 0.4375rem;
- border-bottom: 1px solid var(--c-border-subtle);
+ gap: 0.5rem;
}
-/* Brand mark (parallelogram icon) leading the wordmark; same in both themes. */
-.portal-sidebar__mark {
- height: 1.5rem;
- width: auto;
- display: block;
- flex-shrink: 0;
-}
-
-/* Stirling wordmark (theme-switched in Sidebar.tsx); matches the editor's
- 22px wordmark so the two apps read as one brand. */
-.portal-sidebar__wordmark {
- height: 1.375rem;
- width: auto;
- display: block;
- flex-shrink: 0;
-}
-
-/* Show the wordmark that matches the rendered scheme (black text on light,
- white text on dark). Keyed on data-mantine-color-scheme so it follows the
- actual theme, not the portal's separate (and sometimes stale) theme state. */
-.portal-sidebar__wordmark--dark {
- display: none;
-}
-[data-mantine-color-scheme="dark"] .portal-sidebar__wordmark--light {
- display: none;
-}
-[data-mantine-color-scheme="dark"] .portal-sidebar__wordmark--dark {
- display: block;
-}
-
-/* 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;
-}
-
-/* Nav body */
.portal-sidebar__nav {
- flex: 1 1 auto;
+ flex: 0 1 auto;
overflow-y: auto;
padding: 0.75rem 0.625rem;
display: flex;
@@ -105,11 +144,20 @@
gap: 0.5rem;
}
-/* Each section is a labelled card: a small header above its nav items. */
+.portal-sidebar .sui-navitem {
+ margin-inline: 0.25rem;
+ padding-inline: 0.625rem;
+}
+
+.portal-sidebar .sui-navitem.is-active {
+ width: calc(100% + 0.75rem);
+ margin-inline: -0.375rem;
+ border-radius: 0;
+ border-left: 3px solid var(--c-primary);
+ padding-left: calc(1.25rem - 3px);
+}
+
.portal-sidebar__section {
- background: var(--c-surface);
- border: 1px solid var(--c-border-subtle);
- border-radius: 0.625rem;
padding: 0.5rem 0.375rem 0.375rem;
display: flex;
flex-direction: column;
@@ -119,7 +167,7 @@
.portal-sidebar__section-label {
margin: 0;
padding: 0 0.5rem;
- font-size: 0.6875rem;
+ font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--c-text-subtle);
@@ -131,10 +179,9 @@
gap: 0.125rem;
}
-/* Footer */
.portal-sidebar__footer {
- border-top: 1px solid var(--c-border-subtle);
- padding: 0.5rem 0.625rem 0.75rem;
+ margin: 0 0.625rem 0.75rem;
+ padding: 0.5rem 0.375rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx
index a6f2a97a01..54a66a8e1a 100644
--- a/frontend/editor/src/portal/components/Sidebar.tsx
+++ b/frontend/editor/src/portal/components/Sidebar.tsx
@@ -1,16 +1,14 @@
import { useMediaQuery } from "@mantine/hooks";
-import { ActionIcon, NavItem } from "@app/ui";
-import { AppSwitch } from "@app/components/shared/AppSwitch";
+import { Tooltip } from "@mantine/core";
+import { ActionIcon, NavItem, NavSurface } from "@app/ui";
+import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
+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 { useTheme } from "@portal/contexts/ThemeContext";
import { useUI } from "@portal/contexts/UIContext";
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl";
-import mark from "@app/assets/brand/modern-logo/StirlingProcessorLogoNoText.svg";
-import wordmarkLight from "@app/assets/brand/modern-logo/StirlingLogoBlackText.svg";
-import wordmarkDark from "@app/assets/brand/modern-logo/StirlingLogoWhiteText.svg";
import { CloseIcon, SettingsIcon } from "@portal/components/icons";
import {
GROUP_PROCESSOR,
@@ -30,14 +28,23 @@ const MOBILE_QUERY = "(max-width: 48rem)";
export function Sidebar() {
const { activeView, setActiveView } = useView();
- const { theme } = useTheme();
- const { openSettings, mobileNavOpen, closeMobileNav } = useUI();
+ const {
+ openSettings,
+ mobileNavOpen,
+ closeMobileNav,
+ sidebarCollapsed,
+ toggleSidebarCollapsed,
+ } = useUI();
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useMediaQuery(MOBILE_QUERY, false, {
getInitialValueInEffect: false,
});
+ // Collapse is a desktop-only affordance: on mobile the sidebar is an
+ // 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.
@@ -50,25 +57,35 @@ export function Sidebar() {
// a takeover modal (matching the marketing prototype).
function renderGroup(entries: NavEntry[]) {
- return entries.map((entry) => (
- {
- // Route changes also close the drawer (AppShell), but re-selecting the
- // active view or opening an external tab changes no route — close here.
- closeMobileNav();
- if (entry.externalUrl) {
- window.open(entry.externalUrl, "_blank", "noopener,noreferrer");
- } else {
- setActiveView(id as ViewId);
- }
- }}
- />
- ));
+ return entries.map((entry) => {
+ const label = t(`portal.nav.${entry.id}`);
+ const item = (
+ {
+ // Route changes also close the drawer (AppShell), but re-selecting the
+ // active view or opening an external tab changes no route — close here.
+ closeMobileNav();
+ if (entry.externalUrl) {
+ window.open(entry.externalUrl, "_blank", "noopener,noreferrer");
+ } else {
+ setActiveView(id as ViewId);
+ }
+ }}
+ />
+ );
+ return collapsed ? (
+
+ {item}
+
+ ) : (
+ item
+ );
+ });
}
return (
@@ -76,37 +93,30 @@ export function Sidebar() {
className={
mobileNavOpen ? "portal-sidebar portal-sidebar--open" : "portal-sidebar"
}
+ data-collapsed={collapsed || undefined}
aria-label={t("portal.shell.sidebar.primaryNav")}
// Off-canvas on mobile: remove from the tab order and accessibility tree.
inert={isMobile && !mobileNavOpen}
>
-
- {/* Both wordmarks render; CSS shows the right one per the actual color
- scheme (data-mantine-color-scheme), so it tracks the rendered theme
- rather than the portal's separate theme state. */}
-
-
-
+
+
+
{NAV_SECTIONS.map((section) => (
-
+
{t(section.labelKey)}
{renderGroup(section.entries)}
-
+
))}
-
+
}
onClick={() => openSettings()}
/>
-
+
);
}
diff --git a/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx b/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx
index 63defa92ac..daa066bd70 100644
--- a/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx
+++ b/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx
@@ -68,7 +68,7 @@ export function LinkedInstancesTable({
{t("portal.accountLink.instances.revoked", "Revoked")}
) : (
-
+
{t("portal.accountLink.instances.active", "Active")}
),
diff --git a/frontend/editor/src/portal/components/editor-admin/DeploymentSummaryStrip.tsx b/frontend/editor/src/portal/components/editor-admin/DeploymentSummaryStrip.tsx
index 8546c4364b..2a23b4992a 100644
--- a/frontend/editor/src/portal/components/editor-admin/DeploymentSummaryStrip.tsx
+++ b/frontend/editor/src/portal/components/editor-admin/DeploymentSummaryStrip.tsx
@@ -1,4 +1,5 @@
import { MetricCard, MetricStrip, Skeleton } from "@app/ui";
+import { EditorIcon } from "@portal/components/icons";
import type { DeploymentSummary } from "@portal/api/editorDeploy";
interface Props {
@@ -10,16 +11,16 @@ interface Props {
export function DeploymentSummaryStrip({ summary, loading }: Props) {
if (loading || !summary) {
return (
-
+ }>
{Array.from({ length: 4 }).map((_, i) => (
-
+
))}
);
}
return (
-
+ }>
{summary.metrics.map((m) => (
-
+
{t(`portal.editorAdmin.targets.state.${target.state}`)}
diff --git a/frontend/editor/src/portal/components/editor-admin/InstanceHealthTable.tsx b/frontend/editor/src/portal/components/editor-admin/InstanceHealthTable.tsx
index a83d560038..a61193f17a 100644
--- a/frontend/editor/src/portal/components/editor-admin/InstanceHealthTable.tsx
+++ b/frontend/editor/src/portal/components/editor-admin/InstanceHealthTable.tsx
@@ -69,11 +69,7 @@ export function InstanceHealthTable({ instances }: Props) {
key: "status",
header: t("portal.editorAdmin.health.columns.status"),
render: (i) => (
-
+
{t(INSTANCE_STATUS_LABEL[i.status])}
),
diff --git a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx
index a415abf107..da9831ec4c 100644
--- a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx
+++ b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx
@@ -5,6 +5,7 @@ import {
Card,
EmptyState,
MetricCard,
+ MetricStrip,
StatusBadge,
Table,
Tabs,
@@ -140,7 +141,7 @@ export function AuditTab() {
/>
{data && (
-
+
)}
{!forbidden && (
diff --git a/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx b/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx
index 59e4b04d54..8e96e4d997 100644
--- a/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx
+++ b/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx
@@ -74,11 +74,7 @@ export function DeploymentsTab() {
key: "status",
header: t("portal.infrastructure.deployments.regionColumns.status"),
render: (r) => (
-
+
{t(REGION_LABEL[r.status])}
),
diff --git a/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx b/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx
index 86b563aabd..6d097b33af 100644
--- a/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx
+++ b/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx
@@ -5,6 +5,7 @@ import {
Chip,
EmptyState,
MetricCard,
+ MetricStrip,
ProgressBar,
Select,
StatusBadge,
@@ -65,11 +66,7 @@ export function ModelsTab() {
key: "status",
header: t("portal.infrastructure.models.columns.status"),
render: (m) => (
-
+
{t(MODEL_LABEL[m.status])}
),
@@ -174,7 +171,7 @@ export function ModelsTab() {
/>
{data && (
-
+
)}
diff --git a/frontend/editor/src/portal/components/pipelines/KpiStrip.tsx b/frontend/editor/src/portal/components/pipelines/KpiStrip.tsx
index 39bac738c9..a306536718 100644
--- a/frontend/editor/src/portal/components/pipelines/KpiStrip.tsx
+++ b/frontend/editor/src/portal/components/pipelines/KpiStrip.tsx
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { MetricCard, MetricStrip } from "@app/ui";
+import { PipelinesIcon } from "@portal/components/icons";
import type { PipelinesOverviewResponse } from "@portal/api/pipelines";
/**
@@ -22,7 +23,7 @@ interface KpiStripProps {
export function KpiStrip({ data, loading }: KpiStripProps) {
const { t } = useTranslation();
return (
-
+ }>
{KPI_LABEL_KEYS.map((labelKey, i) => {
const k = loading ? undefined : data?.kpis[i];
return (
diff --git a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx
index 38c46547ca..e0ac5970f6 100644
--- a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx
+++ b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx
@@ -49,11 +49,7 @@ export function PipelinesTable({ pipelines, onRowClick }: PipelinesTableProps) {
key: "status",
header: t("portal.pipelines.table.status"),
render: (p) => (
-
+
{t(`portal.pipelines.status.${p.status}`)}
),
diff --git a/frontend/editor/src/portal/components/policies/CatalogueSummary.tsx b/frontend/editor/src/portal/components/policies/CatalogueSummary.tsx
index aab40030d5..c7e2fd4f26 100644
--- a/frontend/editor/src/portal/components/policies/CatalogueSummary.tsx
+++ b/frontend/editor/src/portal/components/policies/CatalogueSummary.tsx
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { MetricCard, MetricStrip } from "@app/ui";
+import { PoliciesIcon } from "@portal/components/icons";
import type { PoliciesResponse } from "@portal/api/policies";
interface CatalogueSummaryProps {
@@ -16,7 +17,7 @@ export function CatalogueSummary({ data, loading }: CatalogueSummaryProps) {
const { t } = useTranslation();
const s = loading ? undefined : data?.summary;
return (
-
+ }>
+
{paused
? t("portal.policies.status.paused")
: t("portal.policies.status.active")}
diff --git a/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx b/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx
index c5169217dd..58fee82d83 100644
--- a/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx
+++ b/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx
@@ -87,7 +87,6 @@ export function PolicyCategoryCard({
{status === "paused"
? t("portal.policies.status.paused")
diff --git a/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx b/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx
index e198fd92c8..336097d9fa 100644
--- a/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx
+++ b/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx
@@ -196,10 +196,7 @@ export function PolicyDetailPanel({
>
{/* Status + trigger strip */}
-
+
{isPaused
? t("portal.policies.status.paused")
: t("portal.policies.status.active")}
diff --git a/frontend/editor/src/portal/components/sources/KpiStrip.tsx b/frontend/editor/src/portal/components/sources/KpiStrip.tsx
index 16e8f547f3..970a0a7982 100644
--- a/frontend/editor/src/portal/components/sources/KpiStrip.tsx
+++ b/frontend/editor/src/portal/components/sources/KpiStrip.tsx
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { MetricCard, MetricStrip } from "@app/ui";
+import { SourcesIcon } from "@portal/components/icons";
import type { SourcesResponse } from "@portal/api/sources";
/**
@@ -22,7 +23,7 @@ interface KpiStripProps {
export function KpiStrip({ data, loading }: KpiStripProps) {
const { t } = useTranslation();
return (
-
+ }>
{KPI_LABEL_KEYS.map((labelKey, i) => {
const k = loading ? undefined : data?.kpis[i];
return (
diff --git a/frontend/editor/src/portal/components/sources/SourcesTable.tsx b/frontend/editor/src/portal/components/sources/SourcesTable.tsx
index 23017299a1..9d99a8c8ea 100644
--- a/frontend/editor/src/portal/components/sources/SourcesTable.tsx
+++ b/frontend/editor/src/portal/components/sources/SourcesTable.tsx
@@ -61,11 +61,7 @@ export function SourcesTable({ sources, onRowClick }: SourcesTableProps) {
key: "status",
header: t("portal.sources.table.status"),
render: (s) => (
-
+
{t(`portal.sources.status.${s.status}`)}
),
diff --git a/frontend/editor/src/portal/contexts/UIContext.tsx b/frontend/editor/src/portal/contexts/UIContext.tsx
index f55c2f5ee9..fbf572d3cc 100644
--- a/frontend/editor/src/portal/contexts/UIContext.tsx
+++ b/frontend/editor/src/portal/contexts/UIContext.tsx
@@ -17,6 +17,8 @@ interface UIContextValue {
openMobileNav: () => void;
closeMobileNav: () => void;
toggleMobileNav: () => void;
+ sidebarCollapsed: boolean;
+ toggleSidebarCollapsed: () => void;
assistantOpen: boolean;
openAssistant: () => void;
@@ -52,9 +54,29 @@ interface UIContextValue {
const UIContext = createContext(null);
+const SIDEBAR_COLLAPSED_KEY = "stirling.portalSidebarCollapsed";
+
+function readSidebarCollapsed(): boolean {
+ try {
+ return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true";
+ } catch {
+ return false;
+ }
+}
+
+function writeSidebarCollapsed(collapsed: boolean): void {
+ try {
+ window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(collapsed));
+ } catch {
+ // private mode / quota: silently no-op
+ }
+}
+
export function UIProvider({ children }: { children: ReactNode }) {
const [searchOpen, setSearchOpen] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
+ const [sidebarCollapsed, setSidebarCollapsed] =
+ useState(readSidebarCollapsed);
const [assistantOpen, setAssistantOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] = useState<
@@ -85,6 +107,14 @@ export function UIProvider({ children }: { children: ReactNode }) {
closeMobileNav: () => setMobileNavOpen(false),
toggleMobileNav: () => setMobileNavOpen((o) => !o),
+ sidebarCollapsed,
+ toggleSidebarCollapsed: () =>
+ setSidebarCollapsed((c) => {
+ const next = !c;
+ writeSidebarCollapsed(next);
+ return next;
+ }),
+
assistantOpen,
openAssistant: () => setAssistantOpen(true),
closeAssistant: () => setAssistantOpen(false),
@@ -129,6 +159,7 @@ export function UIProvider({ children }: { children: ReactNode }) {
[
searchOpen,
mobileNavOpen,
+ sidebarCollapsed,
assistantOpen,
settingsOpen,
settingsInitialSection,
diff --git a/frontend/editor/src/portal/views/Infrastructure.css b/frontend/editor/src/portal/views/Infrastructure.css
index 2bff218e96..cd532daeeb 100644
--- a/frontend/editor/src/portal/views/Infrastructure.css
+++ b/frontend/editor/src/portal/views/Infrastructure.css
@@ -185,25 +185,6 @@
text-align: right;
}
-/* Metric strip (audit) */
-.portal-infra__metrics {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 0.75rem;
-}
-
-@media (max-width: 50rem) {
- .portal-infra__metrics {
- grid-template-columns: repeat(2, 1fr);
- }
-}
-
-@media (max-width: 30rem) {
- .portal-infra__metrics {
- grid-template-columns: 1fr;
- }
-}
-
/* ── API keys ───────────────────────────────────────────────────────────── */
.portal-infra__keys {
display: flex;
diff --git a/frontend/editor/src/proprietary/auth/ui/AuthShell.module.css b/frontend/editor/src/proprietary/auth/ui/AuthShell.module.css
index bb0e418d88..9690a8f08f 100644
--- a/frontend/editor/src/proprietary/auth/ui/AuthShell.module.css
+++ b/frontend/editor/src/proprietary/auth/ui/AuthShell.module.css
@@ -5,7 +5,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
- background-color: var(--auth-bg-color);
+ background-color: var(--c-bg);
padding: 1.5rem 1.5rem 0;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
diff --git a/frontend/editor/src/proprietary/auth/ui/AuthSignupPrompt.tsx b/frontend/editor/src/proprietary/auth/ui/AuthSignupPrompt.tsx
index baaad54d7b..3a9cbf45f4 100644
--- a/frontend/editor/src/proprietary/auth/ui/AuthSignupPrompt.tsx
+++ b/frontend/editor/src/proprietary/auth/ui/AuthSignupPrompt.tsx
@@ -9,7 +9,7 @@ interface AuthSignupPromptProps {
/**
* "Don't have an account? Sign up" row shown beneath the login form. The prompt
- * is muted; the action reads as a brand-coloured link.
+ * is muted; the action reads as a blue link so it pops.
*/
export default function AuthSignupPrompt({ onSignUp }: AuthSignupPromptProps) {
const { t } = useTranslation();
@@ -19,7 +19,7 @@ export default function AuthSignupPrompt({ onSignUp }: AuthSignupPromptProps) {
diff --git a/frontend/editor/src/proprietary/auth/ui/EmailPasswordForm.tsx b/frontend/editor/src/proprietary/auth/ui/EmailPasswordForm.tsx
index e83715a802..7cd0093a6b 100644
--- a/frontend/editor/src/proprietary/auth/ui/EmailPasswordForm.tsx
+++ b/frontend/editor/src/proprietary/auth/ui/EmailPasswordForm.tsx
@@ -136,9 +136,7 @@ export default function EmailPasswordForm({
fontSize="sm"
loading={isSubmitting}
className="auth-submit"
- // Stirling-red brand CTA; the brand accent sets the colour inline so the
- // host app's Mantine primaryColor can't win (editor vs portal differ).
- accent="brand"
+ accent="default"
>
{submitButtonText}
diff --git a/frontend/editor/src/proprietary/auth/ui/auth-theme.css b/frontend/editor/src/proprietary/auth/ui/auth-theme.css
index 8580d59e0b..520df4e510 100644
--- a/frontend/editor/src/proprietary/auth/ui/auth-theme.css
+++ b/frontend/editor/src/proprietary/auth/ui/auth-theme.css
@@ -2,7 +2,6 @@
:root {
/* Auth page colors (light mode) */
- --auth-bg-color: var(--p-gray-100);
--auth-card-bg: #ffffff;
--auth-label-text: var(--p-gray-700);
--auth-input-border: var(--p-gray-300);
@@ -33,7 +32,6 @@
}
[data-mantine-color-scheme="dark"] {
- --auth-bg-color: var(--c-surface-sunken);
--auth-card-bg: var(--c-surface);
--auth-label-text: var(--c-text-muted);
--auth-input-border: var(--c-border);
diff --git a/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx b/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx
deleted file mode 100644
index 3192b69303..0000000000
--- a/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
-
-const meta = {
- title: "Agents/StirlingLogoOutline",
- component: StirlingLogoOutline,
- parameters: { layout: "centered" },
- args: {
- size: 20,
- },
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-export const Default: Story = {};
-
-export const Large: Story = {
- args: {
- size: 64,
- },
-};
diff --git a/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.tsx b/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.tsx
deleted file mode 100644
index c52b40df24..0000000000
--- a/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * A temp stirling logo, may change in future.
- */
-export function StirlingLogoOutline({ size = 20 }: { size?: number }) {
- return (
-
-
-
-
- );
-}
diff --git a/frontend/editor/src/proprietary/components/chat/ChatPanel.css b/frontend/editor/src/proprietary/components/chat/ChatPanel.css
index e95078bcdf..f301b822af 100644
--- a/frontend/editor/src/proprietary/components/chat/ChatPanel.css
+++ b/frontend/editor/src/proprietary/components/chat/ChatPanel.css
@@ -145,15 +145,11 @@
/* Same treatment for the input pill and quick-action cards. */
[data-mantine-color-scheme="dark"] .chat-panel-input {
background: transparent;
- box-shadow:
- 0 0 0 1px var(--c-border-subtle),
- 0 6px 16px rgba(0, 0, 0, 0.25);
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
}
[data-mantine-color-scheme="dark"] .chat-panel-input:focus-within {
- box-shadow:
- 0 0 0 1px color-mix(in srgb, var(--mantine-color-blue-3) 40%, transparent),
- 0 8px 22px color-mix(in srgb, var(--mantine-color-blue-6) 18%, transparent);
+ box-shadow: 0 8px 22px color-mix(in srgb, var(--c-btn-solid) 18%, transparent);
}
[data-mantine-color-scheme="dark"] .chat-quick-action {
@@ -331,16 +327,12 @@
border-radius: 1.1rem;
background: var(--mantine-color-body);
flex-shrink: 0;
- box-shadow:
- 0 0 0 1px rgba(0, 0, 0, 0.04),
- 0 4px 14px rgba(0, 0, 0, 0.08);
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
transition: box-shadow 160ms ease-out;
}
.chat-panel-input:focus-within {
- box-shadow:
- 0 0 0 1px color-mix(in srgb, var(--mantine-color-blue-6) 20%, transparent),
- 0 6px 18px color-mix(in srgb, var(--mantine-color-blue-6) 12%, transparent);
+ box-shadow: 0 6px 18px color-mix(in srgb, var(--c-btn-solid) 12%, transparent);
}
/* Kill the Mantine Textarea's own border/outline — the wrapper owns the chrome. */
@@ -479,12 +471,12 @@
}
.chat-bubble-user {
- background: var(--mantine-color-blue-filled) !important;
- color: white !important;
+ background: var(--c-btn-solid) !important;
+ color: var(--c-btn-inverse) !important;
}
.chat-bubble-user * {
- color: white !important;
+ color: var(--c-btn-inverse) !important;
}
/* Assistant messages: no bubble, free-flowing with side padding */
@@ -517,12 +509,23 @@
padding: 0.2rem 0.5rem 0.35rem;
}
+[data-mantine-color-scheme="dark"] .chat-panel__header .sui-panelhdr__icon,
+.chat-panel__header .sui-panelhdr__icon {
+ background: transparent;
+ border: none;
+}
+
+.chat-panel__header .chat-panel__header-mark {
+ width: auto;
+ height: 26px;
+}
+
.chat-progress-live__logo {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
- color: var(--mantine-color-blue-filled);
+ color: var(--c-brand-mark);
}
/* Shimmer: a soft highlight sweeps left-to-right across the muted label. */
diff --git a/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx b/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx
index d0ff7e19e3..2ff5ccfbe9 100644
--- a/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx
+++ b/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx
@@ -41,7 +41,8 @@ import {
import { formatRelativeTime } from "@app/utils/timeUtils";
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
import { StirlingLogoAnimated } from "@app/components/agents/StirlingLogoAnimated";
-import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
+import { BrandMark } from "@app/components/shared/BrandMark";
+import { Logo } from "@app/ui/Logo";
import { PanelHeader } from "@app/ui/PanelHeader";
import { ChatQuickActions } from "@app/components/chat/ChatQuickActions";
import "@app/components/chat/ChatPanel.css";
@@ -474,8 +475,14 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
return (
}
- title={t("agents.stirling_name", "Stirling")}
+ icon={ }
+ title={
+
+ }
loading={isLoading}
className="chat-panel__header"
barClassName="chat-panel__agent-pill-vt"
diff --git a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx
index 8e500a0c1a..2e02db3068 100644
--- a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx
+++ b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx
@@ -1,28 +1,29 @@
import { useNavigate } from "react-router-dom";
-import { useMantineColorScheme } from "@mantine/core";
import { useAuth } from "@app/auth/context";
-import { AppSwitch } from "@app/components/shared/AppSwitch";
+import { Logo } from "@app/ui/Logo";
+import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
+import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher";
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
-/**
- * Sidebar app switcher between the editor and the admin portal. Both are
- * route-sets of one SPA (the portal mounts at PORTAL_BASENAME), so switching
- * is a client-side navigation. Hidden for users without portal access — they
- * have nowhere to switch to. Renders the same AppSwitch element as the
- * portal's sidebar.
- */
-export function AppSwitcher() {
+export function AppSwitcher({ collapsed }: AppSwitcherProps) {
const { portalAccess } = useAuth();
const navigate = useNavigate();
- const { colorScheme } = useMantineColorScheme();
- if (!portalAccess) return null;
+ if (!portalAccess) {
+ return (
+
+ );
+ }
return (
- navigate(PORTAL_BASENAME)}
+ collapsed={collapsed}
/>
);
}
diff --git a/frontend/editor/src/proprietary/routes/Login.tsx b/frontend/editor/src/proprietary/routes/Login.tsx
index 3e6764bf60..1686f8c795 100644
--- a/frontend/editor/src/proprietary/routes/Login.tsx
+++ b/frontend/editor/src/proprietary/routes/Login.tsx
@@ -16,10 +16,6 @@ import AuthLayout from "@app/routes/authShared/AuthLayout";
import { useBackendProbe } from "@app/hooks/useBackendProbe";
import { BASE_PATH, withBasePath } from "@app/constants/app";
import { updateSupportedLanguages } from "@app/i18n";
-import {
- DEBUG_SHOW_ALL_PROVIDERS,
- oauthProviderConfig,
-} from "@app/auth/ui/OAuthButtons";
import SpringLoginForm from "@app/auth/ui/SpringLoginForm";
import AuthSignupPrompt from "@app/auth/ui/AuthSignupPrompt";
import AuthDefaultCredentials from "@app/auth/ui/AuthDefaultCredentials";
@@ -48,10 +44,9 @@ export default function Login() {
const { refetch } = useAppConfig();
const { t } = useTranslation();
const [successMessage, setSuccessMessage] = useState(null);
- const [showEmailForm, setShowEmailForm] = useState(false);
+ const [showEmailForm, setShowEmailForm] = useState(true);
const [_enableLogin, setEnableLogin] = useState(null);
const [ssoAutoLogin, setSsoAutoLogin] = useState(false);
- const [hasSSOProviders, setHasSSOProviders] = useState(false);
const backendProbe = useBackendProbe();
const [isFirstTimeSetup, setIsFirstTimeSetup] = useState(false);
const [showDefaultCredentials, setShowDefaultCredentials] = useState(false);
@@ -235,26 +230,13 @@ export default function Login() {
}
}, [backendProbe.status, refetch]);
- // Update hasSSOProviders and showEmailForm when providers or loginMethod change
+ // The email/password form is always shown when username/password auth is
+ // allowed; SSO-only mode hides it.
useEffect(() => {
- // In debug mode, check if any providers exist in the config
- const hasProviders = DEBUG_SHOW_ALL_PROVIDERS
- ? Object.keys(oauthProviderConfig).length > 0
- : login.providers.length > 0;
- setHasSSOProviders(hasProviders);
-
- // Check if username/password authentication is allowed
const userPassAllowed =
login.loginMethod === "all" || login.loginMethod === "normal";
-
- // Show email form if no SSO providers exist AND username/password is allowed
- if (!hasProviders && userPassAllowed) {
- setShowEmailForm(true);
- } else if (!userPassAllowed) {
- // Hide email form if username/password auth is not allowed
- setShowEmailForm(false);
- }
- }, [login.providers, login.loginMethod]);
+ setShowEmailForm(userPassAllowed);
+ }, [login.loginMethod]);
// Auto-login to SSO when enabled and only one SSO option exists
useEffect(() => {
@@ -502,22 +484,6 @@ export default function Login() {
) : undefined
}
- beforeEmailForm={
- hasSSOProviders && !showEmailForm && isUserPassAllowed ? (
-
- setShowEmailForm(true)}
- disabled={login.isSubmitting}
- className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
- >
- {t("login.useEmailInstead", "Login with email")}
-
-
- ) : undefined
- }
footer={
<>
{isFirstTimeSetup &&
diff --git a/frontend/editor/src/proprietary/routes/Signup.tsx b/frontend/editor/src/proprietary/routes/Signup.tsx
index 68be029969..69e2ef9929 100644
--- a/frontend/editor/src/proprietary/routes/Signup.tsx
+++ b/frontend/editor/src/proprietary/routes/Signup.tsx
@@ -135,6 +135,7 @@ export default function Signup() {
variant="tertiary"
onClick={() => navigate("/login")}
className="auth-link-black"
+ style={{ color: "var(--c-primary)" }}
>
{t("login.logIn", "Log In")}
diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx
index 1da92bd190..32af9a6782 100644
--- a/frontend/editor/src/saas/App.tsx
+++ b/frontend/editor/src/saas/App.tsx
@@ -25,7 +25,7 @@ import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect";
// Import global styles
import "@app/styles/tailwind.css";
-import "@app/styles/saas-theme.css";
+import "@app/auth/ui/auth-theme.css";
import "@app/styles/cookieconsent.css";
import "@app/styles/index.css";
diff --git a/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css b/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css
index 344743473a..888401b47d 100644
--- a/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css
+++ b/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css
@@ -1,9 +1,12 @@
+/* Sits as a direct child of the sidebar, so the rail's own padding + gap
+ handle the spacing; a margin here would inset it narrower than the
+ sibling nav-surface boxes. Matches those boxes' surface treatment — the
+ brand mark in the header is what draws the eye, so no louder border. */
.card {
- margin: 0.5rem;
+ margin: 0;
padding: 0.625rem 0.75rem 0.6875rem;
- background: var(--mantine-color-body);
- border: 1px solid
- color-mix(in srgb, var(--mantine-color-default-border) 45%, transparent);
+ background: var(--c-surface);
+ border: 1px solid var(--c-border-subtle);
border-radius: 0.625rem;
box-shadow: none;
}
diff --git a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx
index 3f7b4de5ab..0d534fa088 100644
--- a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx
+++ b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx
@@ -33,6 +33,8 @@ interface AppConfigModalProps {
initialSection?: NavKey | null;
/** Host-specific sections appended after the saas registry sections. */
extraSections?: ConfigNavSection[];
+ /** Registry section keys to drop, for hosts a section can't run in. */
+ hiddenSectionKeys?: NavKey[];
}
const AppConfigModal: React.FC = ({
@@ -40,6 +42,7 @@ const AppConfigModal: React.FC = ({
onClose,
initialSection,
extraSections,
+ hiddenSectionKeys,
}) => {
const isMobile = useMediaQuery("(max-width: 1024px)");
@@ -153,8 +156,23 @@ const AppConfigModal: React.FC = ({
isAnonymous,
t,
});
- return extraSections?.length ? [...sections, ...extraSections] : sections;
- }, [openLogoutConfirm, isDev, isAnonymous, t, extraSections]);
+ const base = hiddenSectionKeys?.length
+ ? sections
+ .map((sec) => ({
+ ...sec,
+ items: sec.items.filter((i) => !hiddenSectionKeys.includes(i.key)),
+ }))
+ .filter((sec) => sec.items.length > 0)
+ : sections;
+ return extraSections?.length ? [...base, ...extraSections] : base;
+ }, [
+ openLogoutConfirm,
+ isDev,
+ isAnonymous,
+ t,
+ extraSections,
+ hiddenSectionKeys,
+ ]);
const activeLabel = useMemo(() => {
for (const section of configNavSections) {
diff --git a/frontend/editor/src/saas/components/shared/AppSwitcher.tsx b/frontend/editor/src/saas/components/shared/AppSwitcher.tsx
new file mode 100644
index 0000000000..364f094478
--- /dev/null
+++ b/frontend/editor/src/saas/components/shared/AppSwitcher.tsx
@@ -0,0 +1,41 @@
+import { useNavigate } from "react-router-dom";
+import { Logo } from "@app/ui/Logo";
+import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
+import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher";
+import { usePortalAccess } from "@app/hooks/usePortalAccess";
+import { PORTAL_BASENAME } from "@app/routes/portalBasename";
+
+/**
+ * SaaS sidebar brand header. When the backend says this user can open the
+ * processor (`/api/v1/auth/me` → `portalAccess` — the exact signal the
+ * processor's own gate uses), the Stirling logo doubles as the
+ * editor⇄processor switcher: the mark morphs into a chevron and opens the
+ * switch menu (same BrandSwitcher the processor sidebar uses). Users without
+ * access get a plain logo.
+ *
+ * Deliberately NOT gated on the editor's Supabase auth context: that context
+ * never fetches /me, so it can't know about portal access (and its session
+ * state doesn't always mirror the backend login that actually grants it).
+ */
+export function AppSwitcher({ collapsed }: AppSwitcherProps) {
+ const portalAccess = usePortalAccess();
+ const navigate = useNavigate();
+
+ if (!portalAccess) {
+ return (
+
+ );
+ }
+
+ return (
+ navigate(PORTAL_BASENAME)}
+ collapsed={collapsed}
+ />
+ );
+}
diff --git a/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx b/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx
new file mode 100644
index 0000000000..a0e8ba618d
--- /dev/null
+++ b/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx
@@ -0,0 +1,108 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+
+const get = vi.fn();
+let currentUserId: string | null = null;
+
+vi.mock("@app/services/apiClient", () => ({
+ default: {
+ get: (...args: unknown[]) => get(...args),
+ },
+}));
+
+vi.mock("@app/auth/UseSession", () => ({
+ useAuth: () => ({ user: currentUserId ? { id: currentUserId } : null }),
+}));
+
+const { usePortalAccess } = await import("@app/hooks/usePortalAccess");
+
+function meReturning(portalAccess: boolean) {
+ return { data: { user: { portalAccess } } };
+}
+
+describe("usePortalAccess", () => {
+ beforeEach(() => {
+ get.mockReset();
+ currentUserId = null;
+ });
+
+ it("reports the backend's answer for the signed-in user", async () => {
+ currentUserId = "admin-1";
+ get.mockResolvedValue(meReturning(true));
+
+ const { result } = renderHook(() => usePortalAccess());
+
+ await waitFor(() => expect(result.current).toBe(true));
+ });
+
+ it("re-asks the backend when a different user signs in without a reload", async () => {
+ // An admin gets a yes...
+ currentUserId = "admin-1";
+ get.mockResolvedValue(meReturning(true));
+ const { result, rerender } = renderHook(() => usePortalAccess());
+ await waitFor(() => expect(result.current).toBe(true));
+
+ // ...then Supabase swaps the identity in place (signed out in another
+ // tab, revoked session, new sign-in) — no page load in between.
+ currentUserId = "member-2";
+ get.mockResolvedValue(meReturning(false));
+ rerender();
+
+ // The member must not inherit the admin's answer.
+ await waitFor(() => expect(result.current).toBe(false));
+ expect(get).toHaveBeenCalledTimes(2);
+ });
+
+ it("drops the answer when the user signs out", async () => {
+ currentUserId = "admin-1";
+ get.mockResolvedValue(meReturning(true));
+ const { result, rerender } = renderHook(() => usePortalAccess());
+ await waitFor(() => expect(result.current).toBe(true));
+
+ currentUserId = null;
+ rerender();
+
+ await waitFor(() => expect(result.current).toBe(false));
+ });
+
+ it("reports no access, and asks nothing, for a guest", () => {
+ currentUserId = null;
+
+ const { result } = renderHook(() => usePortalAccess());
+
+ expect(result.current).toBe(false);
+ expect(get).not.toHaveBeenCalled();
+ });
+
+ it("treats a failed lookup as no access, and a later mount asks again", async () => {
+ currentUserId = "admin-1";
+ get.mockRejectedValueOnce(new Error("401"));
+ const first = renderHook(() => usePortalAccess());
+ await waitFor(() => expect(get).toHaveBeenCalledTimes(1));
+ expect(first.result.current).toBe(false);
+ first.unmount();
+
+ // The failure isn't sticky.
+ get.mockResolvedValue(meReturning(true));
+ const second = renderHook(() => usePortalAccess());
+ await waitFor(() => expect(second.result.current).toBe(true));
+ });
+
+ it("ignores a response that lands after unmount", async () => {
+ currentUserId = "admin-1";
+ let resolveMe: (v: unknown) => void = () => {};
+ get.mockReturnValue(
+ new Promise((resolve) => {
+ resolveMe = resolve;
+ }),
+ );
+
+ const { result, unmount } = renderHook(() => usePortalAccess());
+ unmount();
+ resolveMe(meReturning(true));
+
+ // No state update on an unmounted hook (React would warn); the stale
+ // answer is simply dropped.
+ expect(result.current).toBe(false);
+ });
+});
diff --git a/frontend/editor/src/saas/hooks/usePortalAccess.ts b/frontend/editor/src/saas/hooks/usePortalAccess.ts
new file mode 100644
index 0000000000..442061cbe1
--- /dev/null
+++ b/frontend/editor/src/saas/hooks/usePortalAccess.ts
@@ -0,0 +1,52 @@
+import { useEffect, useState } from "react";
+import apiClient from "@app/services/apiClient";
+import { useAuth } from "@app/auth/UseSession";
+
+/**
+ * Whether the current user can open the processor (admin portal), straight
+ * from the backend (`/api/v1/auth/me` → `portalAccess`) — the same signal the
+ * processor's own SaasPortalGate uses. Components that must mirror processor
+ * access (e.g. the sidebar's editor⇄processor switcher) ask here.
+ *
+ * The editor's Supabase auth context can't *answer* this — it never fetches
+ * /me — so it is used only to identify who is asking. Keying the effect on
+ * that identity is what keeps the answer per-user: the SPA can swap users
+ * without a reload (Supabase fires SIGNED_OUT/SIGNED_IN in place; only the
+ * settings Logout button hard-navigates), so any answer held beyond the
+ * current identity would leak to whoever signs in next.
+ *
+ * Deliberately unmemoised beyond the mount: the one consumer (the sidebar
+ * switcher) mounts once, so a cross-mount cache would only add user-scoped
+ * state that has to be invalidated on identity change — the bug class this
+ * hook already had once. Guests skip the request entirely.
+ */
+export function usePortalAccess(): boolean {
+ const { user } = useAuth();
+ const userId = user?.id ?? null;
+ const [access, setAccess] = useState(false);
+
+ useEffect(() => {
+ // Signed out: nothing to ask, and any previous answer is void.
+ if (userId === null) {
+ setAccess(false);
+ return;
+ }
+
+ let cancelled = false;
+ apiClient
+ .get<{ user?: { portalAccess?: boolean } }>("/api/v1/auth/me")
+ .then((res) => {
+ if (!cancelled) setAccess(res.data.user?.portalAccess === true);
+ })
+ .catch(() => {
+ // Backend unreachable or guest (401): no access now; a remount or
+ // identity change asks again rather than trusting a failure.
+ if (!cancelled) setAccess(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [userId]);
+
+ return access;
+}
diff --git a/frontend/editor/src/saas/routes/Login.tsx b/frontend/editor/src/saas/routes/Login.tsx
index ab75b7bb79..03f654f4d0 100644
--- a/frontend/editor/src/saas/routes/Login.tsx
+++ b/frontend/editor/src/saas/routes/Login.tsx
@@ -30,7 +30,6 @@ export default function Login() {
const [isSigningIn, setIsSigningIn] = useState(false);
const [error, setError] = useState(null);
const [showMagicLinkForm, setShowMagicLinkForm] = useState(false);
- const [showEmailForm, setShowEmailForm] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [magicLinkEmail, setMagicLinkEmail] = useState("");
@@ -43,7 +42,6 @@ export default function Login() {
const emailFromQuery = url.searchParams.get("email");
if (emailFromQuery) {
setEmail(emailFromQuery);
- setShowEmailForm(true);
}
} catch (_) {
// ignore
@@ -256,15 +254,8 @@ export default function Login() {
}
};
- const toggleEmailForm = () => {
- setShowEmailForm((v) => !v);
- setShowMagicLinkForm(false);
- setMagicLinkSent(false);
- };
-
const toggleMagicLink = () => {
setShowMagicLinkForm((v) => !v);
- setShowEmailForm(false);
setMagicLinkSent(false);
};
@@ -374,71 +365,30 @@ export default function Login() {
- {/* Email & Password button */}
-
+ {/* Email + password form — always visible (no expander toggle) */}
+
+
navigate("/auth/reset")}
+ className="auth-link-black"
+ style={{ fontSize: "0.8125rem", marginTop: "0.25rem" }}
>
- {isSigningIn
- ? t("login.signingIn", "Signing in...")
- : `${t("signup.skip", "Skip")} →`}
+ {t("login.forgotPassword", "Forgot your password?")}
- {/* Bottom */}
+ {/* Create an account — pushed to the bottom */}
{t("login.createAccount", "Create an account")}
+
+ {/* Skip — small + muted, at the very bottom */}
+
+
+ {isSigningIn
+ ? t("login.signingIn", "Signing in...")
+ : `${t("signup.skip", "Skip")} →`}
+
+
);
}
diff --git a/frontend/editor/src/saas/routes/Signup.tsx b/frontend/editor/src/saas/routes/Signup.tsx
index 942cfefb5a..e9529944d6 100644
--- a/frontend/editor/src/saas/routes/Signup.tsx
+++ b/frontend/editor/src/saas/routes/Signup.tsx
@@ -29,7 +29,6 @@ export default function Signup() {
const { t } = useTranslation();
const [isSigningUp, setIsSigningUp] = useState(false);
const [error, setError] = useState
(null);
- const [showEmailForm, setShowEmailForm] = useState(false);
const [name, setName] = useState(undefined as string | undefined);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -209,67 +208,26 @@ export default function Signup() {
/>
- {/* Email & Password button */}
-
-
+ {/* Sign-up form — always visible (no expander toggle) */}
+
+
- {/* Skip */}
-
-
- {isSigningUp
- ? t("login.signingIn", "Signing in...")
- : `${t("signup.skip", "Skip")} →`}
-
-
-
- {/* Bottom */}
+ {/* Already have an account — pushed to the bottom */}
{t("signup.alreadyHaveAccount", "I already have an account")}
+
+ {/* Skip — small + muted, at the very bottom */}
+
+
+ {isSigningUp
+ ? t("login.signingIn", "Signing in...")
+ : `${t("signup.skip", "Skip")} →`}
+
+
);
}
diff --git a/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx b/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx
index a7435eb43b..27963cff83 100644
--- a/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx
+++ b/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx
@@ -82,7 +82,7 @@ export default function EmailPasswordForm({
{submitButtonText}
diff --git a/frontend/editor/src/saas/styles/saas-theme.css b/frontend/editor/src/saas/styles/saas-theme.css
deleted file mode 100644
index 0d6b0d4940..0000000000
--- a/frontend/editor/src/saas/styles/saas-theme.css
+++ /dev/null
@@ -1,173 +0,0 @@
-/* SaaS-specific CSS variables — imported alongside core theme.css */
-
-:root {
- /* Orange scale (used for warning toasts) */
- --color-orange-50: var(--p-amber-400);
- --color-orange-100: var(--p-amber-400);
- --color-orange-200: var(--p-red-400);
- --color-orange-300: var(--p-red-400);
- --color-orange-400: var(--p-red-600);
-
- /* Amber scale (trial/warning emphasis) */
- --color-amber-50: var(--p-amber-400);
- --color-amber-100: var(--p-amber-400);
- --color-amber-200: var(--p-amber-400);
- --color-amber-300: var(--p-amber-400);
- --color-amber-400: var(--p-amber-400);
- --color-amber-500: var(--p-amber-500);
- --color-amber-600: var(--p-amber-600);
- --color-amber-700: var(--p-amber-600);
- --color-amber-800: var(--p-amber-600);
- --color-amber-900: var(--p-amber-600);
-
- /* Subcategory / divider vars (light) */
- --tool-subcategory-text-color-light: var(--p-gray-400);
- --tool-subcategory-rule-color-light: var(--p-gray-200);
-
- /* Auth color vars (light mode) */
- --auth-input-bg: var(--p-gray-50);
- --auth-input-border: var(--p-gray-200);
- --auth-input-text: var(--p-gray-800);
- --auth-label-text: var(--p-zinc-650);
- --auth-button-bg: var(--p-red-600);
- --auth-button-text: #ffffff;
- --auth-magic-button-bg: var(--p-red-600);
- --auth-magic-button-text: #ffffff;
- --auth-bg-color: #ffffff;
- --auth-card-bg: #ffffff;
- --auth-text-primary: var(--p-zinc-650);
- --auth-text-secondary: var(--p-gray-800);
- --auth-border-focus: var(--p-gray-300);
- --auth-focus-ring: color-mix(in srgb, var(--p-blue-500) 15%, transparent);
- --auth-error-bg: color-mix(in srgb, var(--p-red-500) 8%, transparent);
- --auth-error-border: color-mix(in srgb, var(--p-red-500) 25%, transparent);
- --auth-error-text: var(--p-red-600);
- --auth-success-bg: color-mix(in srgb, var(--p-green-500) 10%, transparent);
- --auth-success-border: color-mix(
- in srgb,
- var(--p-green-500) 30%,
- transparent
- );
- --auth-success-text: var(--p-green-600);
-
- /* App Config Modal colors (light mode) */
- --modal-nav-bg: var(--p-gray-100);
- --modal-nav-section-title: var(--p-gray-500);
- --modal-nav-item: var(--p-gray-700);
- --modal-nav-item-active: var(--p-blue-500);
- --modal-nav-item-active-bg: color-mix(
- in srgb,
- var(--p-blue-500) 8%,
- transparent
- );
- --modal-content-bg: #ffffff;
- --modal-header-border: rgba(0, 0, 0, 0.06);
-
- /* API usage progress bar colors (light mode) */
- --usage-inactive: var(--p-gray-200);
-
- /* API Keys section colors (light mode) */
- --api-keys-card-bg: #ffffff;
- --api-keys-card-border: var(--p-gray-200);
- --api-keys-card-shadow: rgba(0, 0, 0, 0.06);
- --api-keys-input-bg: var(--p-gray-50);
- --api-keys-input-border: var(--p-gray-200);
-}
-
-[data-mantine-color-scheme="dark"] {
- /* Compare highlight colors (dark mode) */
- --spdf-compare-removed-bg: color-mix(
- in srgb,
- var(--p-red-400) 45%,
- transparent
- );
- --spdf-compare-added-bg: color-mix(
- in srgb,
- var(--p-green-500) 35%,
- transparent
- );
- --spdf-compare-removed-badge-bg: color-mix(
- in srgb,
- var(--p-red-500) 15%,
- transparent
- );
- --spdf-compare-removed-badge-fg: var(--color-red-500);
- --spdf-compare-added-badge-bg: color-mix(
- in srgb,
- var(--p-green-500) 18%,
- transparent
- );
- --spdf-compare-added-badge-fg: var(--color-green-500);
- --spdf-compare-inline-removed-bg: color-mix(
- in srgb,
- var(--p-red-500) 25%,
- transparent
- );
- --spdf-compare-inline-added-bg: color-mix(
- in srgb,
- var(--p-green-500) 25%,
- transparent
- );
- --compare-page-label-bg: var(--p-zinc-850);
- --compare-page-label-fg: var(--p-gray-300);
-
- /* Orange scale (dark mode mirrors light values to match UI) */
- --color-orange-50: var(--p-amber-400);
- --color-orange-100: var(--p-amber-400);
- --color-orange-200: var(--p-red-400);
- --color-orange-300: var(--p-red-400);
- --color-orange-400: var(--p-red-600);
-
- /* Auth page colors (dark mode) — mirror proprietary so the auth card themes dark */
- --auth-bg-color: var(--c-surface-sunken);
- --auth-card-bg: var(--c-surface);
- --auth-label-text: var(--c-text-muted);
- --auth-input-border: var(--c-border);
- --auth-input-bg: var(--c-surface-raised);
- --auth-input-text: var(--c-text);
- --auth-border-focus: var(--p-blue-500);
- --auth-focus-ring: color-mix(in srgb, var(--p-blue-500) 20%, transparent);
- --auth-button-bg: var(--p-red-600);
- --auth-button-text: #ffffff;
- --auth-magic-button-bg: var(--c-surface-raised);
- --auth-magic-button-text: var(--c-text);
- --auth-text-primary: var(--c-text);
- --auth-text-secondary: var(--c-text-muted);
- --auth-error-bg: color-mix(in srgb, var(--p-red-500) 12%, transparent);
- --auth-error-border: color-mix(in srgb, var(--p-red-500) 35%, transparent);
- --auth-error-text: var(--p-red-400);
- --auth-success-bg: color-mix(in srgb, var(--p-green-500) 12%, transparent);
- --auth-success-border: color-mix(
- in srgb,
- var(--p-green-500) 35%,
- transparent
- );
- --auth-success-text: var(--p-green-500);
- --text-divider-rule-rgb-light: 229, 231, 235;
- --text-divider-label-rgb-light: 156, 163, 175;
- --tool-subcategory-rule-color-light: var(--p-gray-200);
- --tool-subcategory-text-color-light: var(--p-gray-400);
-
- /* API usage progress bar colors (dark mode) */
- --usage-inactive: var(--p-zinc-650);
-
- /* API Keys section colors (dark mode) */
- --api-keys-card-bg: var(--p-zinc-800);
- --api-keys-card-border: var(--p-zinc-650);
- --api-keys-card-shadow: none;
- --api-keys-input-bg: var(--p-zinc-850);
- --api-keys-input-border: var(--p-zinc-650);
-
- /* App Config Modal colors (dark mode) */
- --modal-nav-bg: var(--p-zinc-850);
- --modal-nav-section-title: var(--p-zinc-300);
- --modal-nav-item: var(--p-gray-300);
- --modal-nav-item-active: var(--p-blue-500);
- --modal-nav-item-active-bg: color-mix(
- in srgb,
- var(--p-blue-500) 15%,
- transparent
- );
- --modal-content-bg: var(--p-zinc-800);
- --modal-header-border: rgba(255, 255, 255, 0.05);
-}